feat(team): improve review change evidence flow
This commit is contained in:
parent
5d3ec8a8bd
commit
bceef9dec5
41 changed files with 3721 additions and 250 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Troubleshooting
|
||||
|
||||
Most team issues fall into one of five buckets: runtime setup, launch confirmation, task parsing, provider limits, and review state gaps.
|
||||
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
|
||||
|
||||
## Team does not launch
|
||||
|
||||
|
|
@ -12,6 +12,10 @@ Check each item in order:
|
|||
4. **Project path** — the project directory exists and is readable
|
||||
5. **Network / VPN** — some providers drop traffic when a VPN is active
|
||||
|
||||
::: tip
|
||||
Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude --version` or `opencode --version`.
|
||||
:::
|
||||
|
||||
### OpenCode: registered but bootstrap unconfirmed
|
||||
|
||||
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
|
||||
|
|
@ -83,6 +87,12 @@ If the CLI is authenticated in one terminal but the app says it is not, verify t
|
|||
- Double-check the provider name in `config.json` matches the provider prefix in the model string
|
||||
- Ensure the key is not expired or revoked in the provider dashboard
|
||||
|
||||
### Auth diagnostic log
|
||||
|
||||
Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
|
||||
|
||||
Check this file if you see "Not logged in" or auth errors in the packaged app.
|
||||
|
||||
## Lane bootstrap stuck
|
||||
|
||||
For OpenCode secondary lanes:
|
||||
|
|
@ -95,6 +105,41 @@ For OpenCode secondary lanes:
|
|||
|
||||
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
|
||||
|
||||
## Common member states
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `confirmed_alive` + `bootstrapConfirmed` | Healthy and ready |
|
||||
| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof has not been committed yet |
|
||||
| `failed_to_start` + `runtime_process` | Process exists, but launch gate failed. Check diagnostics |
|
||||
| `failed_to_start` + `stale_metadata` | Saved pid/session is stale or dead |
|
||||
|
||||
::: warning
|
||||
`member_briefing` by itself is NOT runtime evidence. For OpenCode, authoritative proof is committed runtime evidence such as `opencode-sessions.json` and the manifest entry.
|
||||
:::
|
||||
|
||||
## Runtime debug mode
|
||||
|
||||
For local debugging, you can force teammates to run in tmux panes:
|
||||
|
||||
```bash
|
||||
# Launch from a terminal
|
||||
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
||||
|
||||
# Or add to custom CLI args
|
||||
--teammate-mode tmux
|
||||
```
|
||||
|
||||
Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
|
||||
|
||||
## Safe cleanup
|
||||
|
||||
When cleaning up stale processes:
|
||||
|
||||
1. Identify the pid and confirm it belongs to the current team / lane.
|
||||
2. Stop only processes explicitly belonging to a smoke test or the launch you are debugging.
|
||||
3. **Do not kill** all OpenCode or shared host processes as a shortcut.
|
||||
|
||||
## When to collect evidence
|
||||
|
||||
Before asking for help, collect:
|
||||
|
|
@ -107,3 +152,7 @@ Before asking for help, collect:
|
|||
- Exact time window when the issue occurred
|
||||
|
||||
This data is usually enough to debug launch and task lifecycle issues.
|
||||
|
||||
::: tip
|
||||
If the issue persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with the live process state before changing code.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -1,45 +1,59 @@
|
|||
# Диагностика
|
||||
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits.
|
||||
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
|
||||
|
||||
## Команда не запускается
|
||||
|
||||
Проверьте:
|
||||
Проверьте последовательно:
|
||||
|
||||
- Выбранный runtime установлен или авторизован
|
||||
- Runtime доступен в environment `PATH`
|
||||
- У провайдера есть доступ к нужной модели
|
||||
- Project path существует и читается
|
||||
1. **Runtime установлен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
|
||||
2. **Доступен в PATH** — бинарник доступен в переменной окружения `PATH`
|
||||
3. **Доступ к модели** — у провайдера есть доступ к запрошенной модели (особенно для OpenCode, важны точные имена провайдера и модели)
|
||||
4. **Путь к проекту** — директория проекта существует и доступна для чтения
|
||||
5. **Сеть / VPN** — некоторые провайдеры блокируют трафик при активном VPN
|
||||
|
||||
::: tip
|
||||
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
|
||||
:::
|
||||
|
||||
### OpenCode: bootstrap не подтверждён
|
||||
### OpenCode: registered, но bootstrap не подтверждён
|
||||
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён:
|
||||
Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
|
||||
|
||||
1. Откройте launch logs в UI.
|
||||
2. Проверьте `~/.claude/teams/<team>/launch-state.json` — состояние member.
|
||||
3. Посмотрите `~/.claude/teams/<team>/.opencode-runtime/lanes/<lane-id>/manifest.json` на наличие evidence.
|
||||
4. Не меняйте team prompts, пока не убедитесь, что lane стартовал, но не смог закоммитить evidence.
|
||||
Посмотрите на последний artifact неудачного запуска:
|
||||
|
||||
::: warning
|
||||
Отсутствие OpenCode inbox во время primary launch — норма. Secondary lanes стартуют после готовности primary filesystem. Не считайте primary hang багом OpenCode, пока UI явно не показывает, что `Y` членов ждёт и `Y` некорректно включает OpenCode lanes.
|
||||
```bash
|
||||
~/.claude/teams/<team>/launch-failure-artifacts/latest.json
|
||||
```
|
||||
|
||||
Манифест внутри включает:
|
||||
|
||||
- `classification` — почему запуск считался неудачным
|
||||
- `bootstrapTransportBreadcrumb` — использованный путь доставки
|
||||
- Статусы старта участников
|
||||
- Редактированные логи и трейсы
|
||||
|
||||
Также проверьте lane manifest:
|
||||
|
||||
```bash
|
||||
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
|
||||
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
|
||||
```
|
||||
|
||||
::: tip Не гадайте по UI
|
||||
Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
|
||||
:::
|
||||
|
||||
## Не видны ответы агента
|
||||
|
||||
Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
|
||||
|
||||
- Runtime delivery gaps
|
||||
- Parsing или task filtering issues
|
||||
- Агент всё ещё обрабатывает (большие задачи могут занимать минуты)
|
||||
- **Runtime delivery retry** — агент мог ответить, но сообщение не доставлено в приложение. Проверьте delivery ledger.
|
||||
- **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
|
||||
- **Task attribution** — работа выполнялась в рамках сессии, но не была привязана к задаче, потому что в выводе отсутствовал корректный task id.
|
||||
|
||||
::: warning Не считайте молчание игнорированием
|
||||
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
|
||||
|
||||
::: tip
|
||||
Для OpenCode teammates проверьте, что вызван `agent-teams_message_send` с правильными `from`, `to` и `taskRefs`. Ответы OpenCode должны отправляться через MCP tools, а не обычным текстом.
|
||||
:::
|
||||
|
||||
## Changes не связаны с tasks
|
||||
|
|
@ -50,9 +64,46 @@
|
|||
- Убедитесь, что агент вызвал `task_add_comment` перед правками.
|
||||
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
|
||||
|
||||
Для OpenCode teammates авторитетным доказательством принадлежности сессии к задаче служат `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
Если провайдер сообщает известное время сброса (reset time), Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
|
||||
|
||||
| Поведение провайдера | Рекомендуемое действие |
|
||||
| --- | --- |
|
||||
| Отображается известное reset time | Дождитесь cooldown и продолжите |
|
||||
| Reset time не показан | Смените провайдера или runtime path |
|
||||
| Повторяющиеся 429 | Снизьте concurrency или используйте другую model lane |
|
||||
|
||||
## Проблемы авторизации CLI
|
||||
|
||||
### `claude login` не сохраняется
|
||||
|
||||
Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён по ожидаемому пути конфигурации, и что процесс приложения видит тот же `$HOME`.
|
||||
|
||||
### OpenCode: ключ провайдера отклонён
|
||||
|
||||
- Убедитесь, что имя провайдера в `config.json` совпадает с префиксом провайдера в строке модели
|
||||
- Проверьте, что ключ не просрочен и не отозван в dashboard провайдера
|
||||
|
||||
### Диагностический лог авторизации
|
||||
|
||||
Каждый вызов `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
||||
|
||||
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
||||
|
||||
## Lane bootstrap stuck
|
||||
|
||||
Для OpenCode secondary lanes:
|
||||
|
||||
- Отсутствие `inboxes/<member>.json` автоматически не является багом. OpenCode lanes не обязаны быть созданы через primary inbox перед стартом.
|
||||
- Если UI показывает, что команда всё ещё запускается, в то время как primary participants уже работоспособны, ожидание «all teammates joined» связано с secondary lanes.
|
||||
- Если зависает `Prepared communication channels for X/Y members`, проверьте, не включает ли `Y` некорректно secondary OpenCode members.
|
||||
|
||||
### Lane manifest empty entries
|
||||
|
||||
Если bridge сообщает, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Участник не должен считаться deliverable, пока `opencode-sessions.json` и запись в manifest не существуют.
|
||||
|
||||
## Распространённые состояния member
|
||||
|
||||
|
|
@ -81,17 +132,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
|||
|
||||
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
|
||||
|
||||
## CLI auth diagnostic
|
||||
|
||||
Каждый запуск `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
|
||||
|
||||
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
|
||||
|
||||
## Безопасная очистка
|
||||
|
||||
При очистке stale processes:
|
||||
|
||||
1. Определите pid и убедитесь, что он принадлежит текущей команде/lane.
|
||||
1. Определите pid и убедитесь, что он принадлежит текущей команде / lane.
|
||||
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
|
||||
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
|
||||
|
||||
|
|
@ -99,11 +144,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
|
|||
|
||||
Соберите:
|
||||
|
||||
- task id
|
||||
- task id (short или full)
|
||||
- team name
|
||||
- runtime path
|
||||
- launch log excerpt
|
||||
- provider/model
|
||||
- runtime path (`claude`, `codex`, или `opencode`)
|
||||
- launch log excerpt (из `latest.json` или `bootstrap-journal.jsonl`)
|
||||
- provider / model
|
||||
- точный time window
|
||||
|
||||
Этого обычно хватает для диагностики launch и task lifecycle issues.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain';
|
||||
export { resolveAgentAttachmentCapability, type AgentAttachmentCapability } from '../core/domain';
|
||||
export * from './optimizeImageForAgent';
|
||||
|
|
|
|||
|
|
@ -484,6 +484,8 @@ export class MemberWorkSyncNudgeDispatcher {
|
|||
teamName: item.teamName,
|
||||
memberName: item.memberName,
|
||||
nowIso,
|
||||
workSyncIntent: item.payload.workSyncIntent,
|
||||
taskRefs: item.payload.taskRefs,
|
||||
});
|
||||
if (busy?.busy) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ export interface MemberWorkSyncBusySignalPort {
|
|||
teamName: string;
|
||||
memberName: string;
|
||||
nowIso: string;
|
||||
workSyncIntent?: MemberWorkSyncOutboxItem['payload']['workSyncIntent'];
|
||||
taskRefs?: MemberWorkSyncOutboxItem['payload']['taskRefs'];
|
||||
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import {
|
|||
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
|
||||
import { parseInboxJson } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isReviewPickupEscalationMessage } from '@shared/utils/teamAutomationMessages';
|
||||
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
|
||||
import { createHash } from 'crypto';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
|
|
@ -568,6 +569,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
|
||||
// not user-visible conversation messages.
|
||||
if (isTeamInternalControlMessageEnvelope(msg)) continue;
|
||||
// Skip internal review-pickup escalations. They are control-plane signals to the lead runtime,
|
||||
// not user-facing inbox messages.
|
||||
if (isReviewPickupEscalationMessage(msg)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -3059,7 +3059,9 @@ async function handleSendMessage(
|
|||
queuedBehindMessageId: delivery.queuedBehindMessageId,
|
||||
reason: delivery.reason,
|
||||
diagnostics: delivery.diagnostics,
|
||||
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
|
||||
userVisibleImpact:
|
||||
delivery.userVisibleImpact ??
|
||||
provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
|
||||
};
|
||||
if (
|
||||
!delivery.delivered &&
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiag
|
|||
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
import {
|
||||
getTaskChangeStateBucket,
|
||||
isTaskChangeSummaryCacheable,
|
||||
|
|
@ -1542,8 +1543,12 @@ export class ChangeExtractorService {
|
|||
return;
|
||||
}
|
||||
|
||||
const reviewability = classifyTaskChangeReviewability(result);
|
||||
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
|
||||
if (!resolvedPresence) {
|
||||
if (reviewability.reviewability === 'diagnostic_only') {
|
||||
await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import {
|
||||
createTaskChangeDiagnosticFromWarning,
|
||||
mergeTaskChangeReviewDiagnostics,
|
||||
} from '@shared/utils/taskChangeReviewability';
|
||||
import { createHash } from 'crypto';
|
||||
import { diffLines } from 'diff';
|
||||
import { open, readFile } from 'fs/promises';
|
||||
|
|
@ -14,6 +18,7 @@ import type {
|
|||
SnippetDiff,
|
||||
TaskChangeJournalStamp,
|
||||
TaskChangeProvenance,
|
||||
TaskChangeReviewDiagnostic,
|
||||
TaskChangeScope,
|
||||
TaskChangeSetV2,
|
||||
} from '@shared/types';
|
||||
|
|
@ -847,6 +852,7 @@ export class TaskChangeLedgerReader {
|
|||
provenance: TaskChangeProvenance;
|
||||
extraWarnings?: string[];
|
||||
}): TaskChangeSetV2 {
|
||||
const warnings = [...params.bundle.warnings, ...(params.extraWarnings ?? [])];
|
||||
return {
|
||||
teamName: params.teamName,
|
||||
taskId: params.taskId,
|
||||
|
|
@ -857,7 +863,14 @@ export class TaskChangeLedgerReader {
|
|||
confidence: params.bundle.confidence,
|
||||
computedAt: params.bundle.generatedAt,
|
||||
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files),
|
||||
warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])],
|
||||
warnings,
|
||||
reviewDiagnostics: this.withSummaryStateDiagnostics(
|
||||
this.buildReviewDiagnosticsFromWarnings(warnings, 'ledger'),
|
||||
{
|
||||
integrity: params.bundle.integrity,
|
||||
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
||||
}
|
||||
),
|
||||
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
||||
provenance: params.provenance,
|
||||
};
|
||||
|
|
@ -878,6 +891,13 @@ export class TaskChangeLedgerReader {
|
|||
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||
recovered: params.journal.recovered,
|
||||
});
|
||||
const reviewDiagnostics = this.collectReviewDiagnostics(
|
||||
projectedEvents,
|
||||
params.journal.notices,
|
||||
{
|
||||
recovered: params.journal.recovered,
|
||||
}
|
||||
);
|
||||
|
||||
let files: FileChangeSummary[];
|
||||
let totalLinesAdded: number;
|
||||
|
|
@ -930,8 +950,12 @@ export class TaskChangeLedgerReader {
|
|||
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
? 'complete'
|
||||
: 'partial';
|
||||
warnings.push(
|
||||
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'
|
||||
const fallbackWarning =
|
||||
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.';
|
||||
warnings.push(fallbackWarning);
|
||||
this.addReviewDiagnostic(
|
||||
reviewDiagnostics,
|
||||
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -946,6 +970,10 @@ export class TaskChangeLedgerReader {
|
|||
computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
|
||||
scope,
|
||||
warnings,
|
||||
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
|
||||
integrity: params.bundle?.integrity ?? params.provenance.integrity,
|
||||
diffStatCompleteness,
|
||||
}),
|
||||
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
||||
provenance: params.provenance,
|
||||
};
|
||||
|
|
@ -967,6 +995,27 @@ export class TaskChangeLedgerReader {
|
|||
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
|
||||
const grouped = this.groupSnippets(snippets);
|
||||
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
||||
const fallbackWarning = 'Task change summary fell back to journal reconstruction.';
|
||||
const warnings = [
|
||||
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||
recovered: params.journal.recovered,
|
||||
}),
|
||||
fallbackWarning,
|
||||
];
|
||||
const reviewDiagnostics = this.collectReviewDiagnostics(
|
||||
projectedEvents,
|
||||
params.journal.notices,
|
||||
{
|
||||
recovered: params.journal.recovered,
|
||||
}
|
||||
);
|
||||
this.addReviewDiagnostic(
|
||||
reviewDiagnostics,
|
||||
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
|
||||
);
|
||||
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
? 'complete'
|
||||
: 'partial';
|
||||
return {
|
||||
teamName: params.teamName,
|
||||
taskId: params.taskId,
|
||||
|
|
@ -986,15 +1035,12 @@ export class TaskChangeLedgerReader {
|
|||
projectedEvents,
|
||||
params.journal.notices
|
||||
),
|
||||
warnings: [
|
||||
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||
recovered: params.journal.recovered,
|
||||
warnings,
|
||||
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
|
||||
integrity: provenance.integrity,
|
||||
diffStatCompleteness,
|
||||
}),
|
||||
'Task change summary fell back to journal reconstruction.',
|
||||
],
|
||||
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
diffStatCompleteness,
|
||||
provenance,
|
||||
};
|
||||
}
|
||||
|
|
@ -1017,6 +1063,10 @@ export class TaskChangeLedgerReader {
|
|||
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.'
|
||||
);
|
||||
for (const notice of params.bundle.notices ?? []) warnings.add(notice.message);
|
||||
const warningList = [...warnings];
|
||||
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
? 'complete'
|
||||
: 'partial';
|
||||
|
||||
return {
|
||||
teamName: params.teamName,
|
||||
|
|
@ -1035,10 +1085,12 @@ export class TaskChangeLedgerReader {
|
|||
params.bundle.events,
|
||||
params.bundle.notices ?? []
|
||||
),
|
||||
warnings: [...warnings],
|
||||
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
warnings: warningList,
|
||||
reviewDiagnostics: this.withSummaryStateDiagnostics(
|
||||
this.buildReviewDiagnosticsFromWarnings(warningList, 'ledger'),
|
||||
{ diffStatCompleteness }
|
||||
),
|
||||
diffStatCompleteness,
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: this.hashFingerprintPayload({
|
||||
|
|
@ -1478,6 +1530,150 @@ export class TaskChangeLedgerReader {
|
|||
};
|
||||
}
|
||||
|
||||
private addReviewDiagnostic(
|
||||
diagnostics: TaskChangeReviewDiagnostic[],
|
||||
diagnostic: TaskChangeReviewDiagnostic
|
||||
): void {
|
||||
const existingIndex = diagnostics.findIndex(
|
||||
(existing) => existing.code === diagnostic.code && existing.message === diagnostic.message
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const existing = diagnostics[existingIndex];
|
||||
if (existing) {
|
||||
diagnostics[existingIndex] = mergeTaskChangeReviewDiagnostics(existing, diagnostic);
|
||||
}
|
||||
return;
|
||||
}
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
|
||||
private buildReviewDiagnosticsFromWarnings(
|
||||
warnings: string[],
|
||||
source: TaskChangeReviewDiagnostic['source']
|
||||
): TaskChangeReviewDiagnostic[] {
|
||||
const diagnostics: TaskChangeReviewDiagnostic[] = [];
|
||||
for (const warning of warnings) {
|
||||
this.addReviewDiagnostic(diagnostics, createTaskChangeDiagnosticFromWarning(warning, source));
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
private diagnosticFromNotice(notice: LedgerNotice): TaskChangeReviewDiagnostic {
|
||||
if (notice.code === 'multi-scope-skipped') {
|
||||
return {
|
||||
code: 'multi_scope_no_safe_diff',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message:
|
||||
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||
source: 'ledger',
|
||||
};
|
||||
}
|
||||
if (notice.code === 'journal-recovered') {
|
||||
return {
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
source: 'ledger',
|
||||
};
|
||||
}
|
||||
if (notice.code === 'writer-lock-stolen') {
|
||||
return {
|
||||
code: 'unsafe_or_untrusted_evidence',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger writer lock changed while evidence was being recorded.',
|
||||
source: 'ledger',
|
||||
};
|
||||
}
|
||||
return createTaskChangeDiagnosticFromWarning(notice.message, 'ledger');
|
||||
}
|
||||
|
||||
private collectReviewDiagnostics(
|
||||
events: LedgerEvent[],
|
||||
notices: LedgerNotice[],
|
||||
options: { recovered: boolean }
|
||||
): TaskChangeReviewDiagnostic[] {
|
||||
const diagnostics: TaskChangeReviewDiagnostic[] = [];
|
||||
for (const notice of notices) {
|
||||
this.addReviewDiagnostic(diagnostics, this.diagnosticFromNotice(notice));
|
||||
}
|
||||
for (const event of events) {
|
||||
for (const warning of event.warnings ?? []) {
|
||||
this.addReviewDiagnostic(
|
||||
diagnostics,
|
||||
createTaskChangeDiagnosticFromWarning(warning, 'ledger')
|
||||
);
|
||||
}
|
||||
if (event.toolStatus === 'failed') {
|
||||
this.addReviewDiagnostic(diagnostics, {
|
||||
code: 'tool_failed_after_edit',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: `Tool ${event.toolUseId} failed after changing files.`,
|
||||
source: 'ledger',
|
||||
});
|
||||
}
|
||||
if (event.toolStatus === 'killed') {
|
||||
this.addReviewDiagnostic(diagnostics, {
|
||||
code: 'tool_killed_after_edit',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: `Background tool ${event.toolUseId} was killed after changing files.`,
|
||||
source: 'ledger',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (options.recovered) {
|
||||
this.addReviewDiagnostic(diagnostics, {
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
source: 'ledger',
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
private withSummaryStateDiagnostics(
|
||||
diagnostics: TaskChangeReviewDiagnostic[],
|
||||
state: {
|
||||
integrity?: 'ok' | 'recovered' | 'partial';
|
||||
diffStatCompleteness?: 'complete' | 'partial';
|
||||
}
|
||||
): TaskChangeReviewDiagnostic[] {
|
||||
const next = [...diagnostics];
|
||||
if (state.diffStatCompleteness === 'partial') {
|
||||
this.addReviewDiagnostic(next, {
|
||||
code: 'diff_stat_partial',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'Some file change statistics are incomplete.',
|
||||
source: 'summary',
|
||||
});
|
||||
}
|
||||
if (state.integrity === 'partial') {
|
||||
this.addReviewDiagnostic(next, {
|
||||
code: 'ledger_integrity_partial',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger is partially available.',
|
||||
source: 'ledger',
|
||||
});
|
||||
} else if (state.integrity === 'recovered') {
|
||||
this.addReviewDiagnostic(next, {
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
source: 'ledger',
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private collectWarnings(
|
||||
events: LedgerEvent[],
|
||||
notices: LedgerNotice[],
|
||||
|
|
|
|||
|
|
@ -233,6 +233,7 @@ import {
|
|||
inspectOpenCodeRuntimeLaneStorage,
|
||||
migrateLegacyOpenCodeRuntimeState,
|
||||
OpenCodeRuntimeManifestEvidenceReader,
|
||||
prepareOpenCodeRuntimeLaneForLaunchGeneration,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||
|
|
@ -9042,10 +9043,19 @@ export class TeamProvisioningService {
|
|||
? error.code
|
||||
: 'opencode_attachment_delivery_prepare_failed';
|
||||
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`;
|
||||
const userVisibleMessage =
|
||||
error instanceof AgentAttachmentError
|
||||
? error.message
|
||||
: 'OpenCode could not prepare the attachment for live delivery.';
|
||||
return {
|
||||
delivered: false,
|
||||
reason,
|
||||
diagnostics: [diagnostic],
|
||||
userVisibleImpact: {
|
||||
state: 'error',
|
||||
reasonCode: 'backend_error',
|
||||
message: userVisibleMessage,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -12288,6 +12298,8 @@ export class TeamProvisioningService {
|
|||
teamName: string;
|
||||
memberName: string;
|
||||
nowIso: string;
|
||||
workSyncIntent?: 'agenda_sync' | 'review_pickup';
|
||||
taskRefs?: TaskRef[];
|
||||
}): Promise<{
|
||||
busy: boolean;
|
||||
reason?: string;
|
||||
|
|
@ -12318,7 +12330,10 @@ export class TeamProvisioningService {
|
|||
const foregroundMessages = inboxMessages.filter(
|
||||
(message) => message.messageKind !== 'member_work_sync_nudge'
|
||||
);
|
||||
const unreadForeground = foregroundMessages.find(
|
||||
const blockingForegroundMessages = foregroundMessages.filter(
|
||||
(message) => !this.isCurrentReviewPickupRequestForegroundMessage(message, input)
|
||||
);
|
||||
const unreadForeground = blockingForegroundMessages.find(
|
||||
(message) =>
|
||||
!message.read &&
|
||||
typeof message.text === 'string' &&
|
||||
|
|
@ -12335,7 +12350,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
const recentForeground = foregroundMessages.find((message) => {
|
||||
const recentForeground = blockingForegroundMessages.find((message) => {
|
||||
const timestampMs = Date.parse(message.timestamp);
|
||||
return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000;
|
||||
});
|
||||
|
|
@ -12441,7 +12456,7 @@ export class TeamProvisioningService {
|
|||
reasonCode: input.reason
|
||||
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
|
||||
: undefined,
|
||||
message: input.reason,
|
||||
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||
};
|
||||
}
|
||||
if (input.delivered === false) {
|
||||
|
|
@ -12453,18 +12468,84 @@ export class TeamProvisioningService {
|
|||
return {
|
||||
state: 'checking',
|
||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
||||
message: reason,
|
||||
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: 'error',
|
||||
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
|
||||
message: reason,
|
||||
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
|
||||
};
|
||||
}
|
||||
return input.policyImpact ?? { state: 'none' };
|
||||
}
|
||||
|
||||
private selectOpenCodeRuntimeDeliveryUserVisibleMessage(input: {
|
||||
reason?: string;
|
||||
diagnostics?: string[];
|
||||
}): string | undefined {
|
||||
const attachmentMessage = this.selectOpenCodeAttachmentDeliveryUserVisibleMessage(input);
|
||||
if (attachmentMessage) {
|
||||
return attachmentMessage;
|
||||
}
|
||||
return input.reason;
|
||||
}
|
||||
|
||||
private selectOpenCodeAttachmentDeliveryUserVisibleMessage(input: {
|
||||
reason?: string;
|
||||
diagnostics?: string[];
|
||||
}): string | undefined {
|
||||
const reason = input.reason?.trim();
|
||||
const isAttachmentFailure =
|
||||
this.isOpenCodeAttachmentDeliveryFailureReason(reason) ||
|
||||
input.diagnostics?.some((diagnostic) =>
|
||||
diagnostic.trim().startsWith('opencode_attachment_delivery_prepare_failed:')
|
||||
) === true;
|
||||
if (!isAttachmentFailure) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const diagnosticMessage = input.diagnostics
|
||||
?.map((diagnostic) => diagnostic.trim())
|
||||
.find((diagnostic) => diagnostic.startsWith('opencode_attachment_delivery_prepare_failed:'));
|
||||
const strippedDiagnostic = diagnosticMessage
|
||||
?.slice('opencode_attachment_delivery_prepare_failed:'.length)
|
||||
.trim();
|
||||
if (strippedDiagnostic) {
|
||||
return strippedDiagnostic;
|
||||
}
|
||||
|
||||
if (reason === 'attachment_model_unsupported') {
|
||||
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
|
||||
}
|
||||
if (reason === 'attachment_type_unsupported') {
|
||||
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
|
||||
}
|
||||
if (reason === 'attachment_too_large') {
|
||||
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
|
||||
}
|
||||
if (reason === 'attachment_artifact_missing' || reason === 'attachment_artifact_path_unsafe') {
|
||||
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
|
||||
}
|
||||
if (reason === 'attachment_optimization_failed') {
|
||||
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
|
||||
}
|
||||
if (reason === 'attachment_provider_rejected') {
|
||||
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
|
||||
}
|
||||
if (reason === 'attachment_runtime_transport_failed') {
|
||||
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isOpenCodeAttachmentDeliveryFailureReason(reason: string | undefined): boolean {
|
||||
return (
|
||||
reason === 'opencode_attachment_delivery_prepare_failed' ||
|
||||
reason?.startsWith('attachment_') === true
|
||||
);
|
||||
}
|
||||
|
||||
private toOpenCodeRuntimeDeliveryStatus(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
|
||||
|
|
@ -21395,8 +21476,9 @@ export class TeamProvisioningService {
|
|||
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']),
|
||||
];
|
||||
if (
|
||||
delivery.reason !== 'opencode_runtime_not_active' ||
|
||||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName)
|
||||
!this.isOpenCodeAttachmentDeliveryFailureReason(delivery.reason) &&
|
||||
(delivery.reason !== 'opencode_runtime_not_active' ||
|
||||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName))
|
||||
) {
|
||||
logger.warn(
|
||||
`[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${
|
||||
|
|
@ -21493,6 +21575,57 @@ export class TeamProvisioningService {
|
|||
return typeof message.messageId === 'string' && message.messageId.trim().length > 0;
|
||||
}
|
||||
|
||||
private isCurrentReviewPickupRequestForegroundMessage(
|
||||
message: InboxMessage,
|
||||
input: { workSyncIntent?: 'agenda_sync' | 'review_pickup'; taskRefs?: TaskRef[] }
|
||||
): boolean {
|
||||
if (input.workSyncIntent !== 'review_pickup') {
|
||||
return false;
|
||||
}
|
||||
if (message.source !== 'system_notification') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedRefs = this.normalizeOpenCodeTaskRefsForComparison(input.taskRefs);
|
||||
if (expectedRefs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = typeof message.summary === 'string' ? message.summary.trim() : '';
|
||||
const text = typeof message.text === 'string' ? message.text : '';
|
||||
const looksLikeReviewRequest =
|
||||
summary.startsWith('Review request for #') ||
|
||||
(text.includes('**Please review**') && text.includes('review_start'));
|
||||
if (!looksLikeReviewRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageRefs = this.normalizeOpenCodeTaskRefsForComparison(message.taskRefs);
|
||||
if (messageRefs.length > 0) {
|
||||
const expectedKeys = new Set(expectedRefs.map((taskRef) => this.openCodeTaskRefKey(taskRef)));
|
||||
return messageRefs.some((taskRef) => expectedKeys.has(this.openCodeTaskRefKey(taskRef)));
|
||||
}
|
||||
|
||||
return expectedRefs.some((taskRef) =>
|
||||
this.openCodeReviewPickupRequestTextMentionsTask({ summary, text, taskRef })
|
||||
);
|
||||
}
|
||||
|
||||
private openCodeReviewPickupRequestTextMentionsTask(input: {
|
||||
summary: string;
|
||||
text: string;
|
||||
taskRef: TaskRef;
|
||||
}): boolean {
|
||||
const displayId = input.taskRef.displayId.trim();
|
||||
const taskId = input.taskRef.taskId.trim();
|
||||
const haystack = `${input.summary}\n${input.text}`;
|
||||
return (
|
||||
(displayId.length > 0 &&
|
||||
(haystack.includes(`#${displayId}`) || haystack.includes(`task #${displayId}`))) ||
|
||||
(taskId.length > 0 && haystack.includes(taskId))
|
||||
);
|
||||
}
|
||||
|
||||
private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
|
||||
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
|
||||
return from === 'user' || message.source === 'user_sent';
|
||||
|
|
@ -25303,12 +25436,13 @@ export class TeamProvisioningService {
|
|||
|
||||
lane.state = 'launching';
|
||||
lane.runId = lane.runId ?? randomUUID();
|
||||
const laneRunId = lane.runId;
|
||||
lane.warnings = [];
|
||||
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
|
||||
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
||||
this.setSecondaryRuntimeRun({
|
||||
teamName: run.teamName,
|
||||
runId: lane.runId,
|
||||
runId: laneRunId,
|
||||
providerId: 'opencode',
|
||||
laneId: lane.laneId,
|
||||
memberName: lane.member.name,
|
||||
|
|
@ -25322,11 +25456,12 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
runId: lane.runId,
|
||||
runId: laneRunId,
|
||||
reason: 'mixed_secondary_launch',
|
||||
});
|
||||
if (shouldAbortLaunch()) {
|
||||
await finishCancelledLane();
|
||||
|
|
@ -25340,8 +25475,9 @@ export class TeamProvisioningService {
|
|||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
const rawResult = await adapter.launch({
|
||||
runId: lane.runId,
|
||||
const launchOpenCodeLane = () =>
|
||||
adapter.launch({
|
||||
runId: laneRunId,
|
||||
laneId: lane.laneId,
|
||||
teamName: run.teamName,
|
||||
cwd: laneCwd,
|
||||
|
|
@ -25365,6 +25501,40 @@ export class TeamProvisioningService {
|
|||
],
|
||||
previousLaunchState,
|
||||
});
|
||||
let rawResult: TeamRuntimeLaunchResult;
|
||||
try {
|
||||
rawResult = await launchOpenCodeLane();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const staleManifestMessage = 'Bridge server runtime manifest high watermark is stale';
|
||||
if (
|
||||
message !== staleManifestMessage &&
|
||||
message !== `OpenCode bridge failed: ${staleManifestMessage}`
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
if (shouldAbortLaunch()) {
|
||||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
const recovery = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: run.teamName,
|
||||
laneId: lane.laneId,
|
||||
runId: laneRunId,
|
||||
reason: 'mixed_secondary_launch_stale_manifest_recovery',
|
||||
forceReset: true,
|
||||
});
|
||||
lane.diagnostics = appendDiagnosticOnce(
|
||||
[...lane.diagnostics, ...recovery.diagnostics],
|
||||
'Retried OpenCode secondary launch after resetting stale runtime manifest.'
|
||||
);
|
||||
if (shouldAbortLaunch()) {
|
||||
await finishCancelledLane();
|
||||
return;
|
||||
}
|
||||
rawResult = await launchOpenCodeLane();
|
||||
}
|
||||
if (shouldAbortLaunch()) {
|
||||
await finishCancelledLane();
|
||||
return;
|
||||
|
|
@ -25467,7 +25637,7 @@ export class TeamProvisioningService {
|
|||
lane.launchFinishedAtMs = Date.now();
|
||||
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
|
||||
lane.result = {
|
||||
runId: lane.runId,
|
||||
runId: laneRunId,
|
||||
teamName: run.teamName,
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'partial_failure',
|
||||
|
|
|
|||
|
|
@ -140,4 +140,36 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos
|
|||
this.writeChains.set(teamName, next);
|
||||
await next;
|
||||
}
|
||||
|
||||
async deleteEntry(teamName: string, taskId: string): Promise<void> {
|
||||
const write = async (): Promise<void> => {
|
||||
const current = await this.readIndex(teamName);
|
||||
if (!current?.entries[taskId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = { ...current.entries };
|
||||
delete entries[taskId];
|
||||
const next = toPersistedTaskChangePresenceIndex({
|
||||
...current,
|
||||
writtenAt: new Date().toISOString(),
|
||||
entries,
|
||||
});
|
||||
|
||||
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
|
||||
};
|
||||
|
||||
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(write)
|
||||
.finally(() => {
|
||||
if (this.writeChains.get(teamName) === next) {
|
||||
this.writeChains.delete(teamName);
|
||||
}
|
||||
});
|
||||
|
||||
this.writeChains.set(teamName, next);
|
||||
await next;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ export interface TaskChangePresenceRepository {
|
|||
logSourceGeneration: string;
|
||||
}
|
||||
): Promise<void>;
|
||||
deleteEntry?(teamName: string, taskId: string): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '@shared/types/review';
|
||||
|
||||
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
|
||||
|
||||
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
|
||||
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types';
|
||||
import type {
|
||||
FileChangeSummary,
|
||||
TaskChangeJournalFileStamp,
|
||||
TaskChangeJournalStamp,
|
||||
TaskChangeProvenance,
|
||||
TaskChangeReviewDiagnostic,
|
||||
TaskChangeSetV2,
|
||||
} from '@shared/types';
|
||||
|
||||
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
|
||||
|
||||
function isTaskChangeDiagnosticCode(value: unknown): value is TaskChangeReviewDiagnostic['code'] {
|
||||
return typeof value === 'string' && TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(value);
|
||||
}
|
||||
|
||||
function normalizeIsoString(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.trim() === '') return null;
|
||||
|
|
@ -31,6 +46,87 @@ function normalizeFileSummary(value: unknown): FileChangeSummary | null {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeReviewDiagnostic(value: unknown): TaskChangeReviewDiagnostic | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
|
||||
if (
|
||||
!isTaskChangeDiagnosticCode(candidate.code) ||
|
||||
(candidate.severity !== 'info' &&
|
||||
candidate.severity !== 'warning' &&
|
||||
candidate.severity !== 'error') ||
|
||||
typeof candidate.reviewBlocking !== 'boolean' ||
|
||||
typeof candidate.message !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
code: candidate.code,
|
||||
severity: candidate.severity,
|
||||
reviewBlocking: candidate.reviewBlocking,
|
||||
message: candidate.message,
|
||||
...(candidate.source === 'ledger' ||
|
||||
candidate.source === 'legacy' ||
|
||||
candidate.source === 'summary' ||
|
||||
candidate.source === 'runtime'
|
||||
? { source: candidate.source }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJournalFileStamp(value: unknown): TaskChangeJournalFileStamp | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const candidate = value as Partial<TaskChangeJournalFileStamp>;
|
||||
if (!Number.isFinite(candidate.bytes) || !Number.isFinite(candidate.mtimeMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: Number(candidate.bytes),
|
||||
mtimeMs: Number(candidate.mtimeMs),
|
||||
tailSha256: typeof candidate.tailSha256 === 'string' ? candidate.tailSha256 : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJournalStamp(value: unknown): TaskChangeJournalStamp | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const candidate = value as Partial<TaskChangeJournalStamp>;
|
||||
const events = normalizeJournalFileStamp(candidate.events);
|
||||
const notices = normalizeJournalFileStamp(candidate.notices);
|
||||
if (!events && !notices) return undefined;
|
||||
return {
|
||||
...(events ? { events } : {}),
|
||||
...(notices ? { notices } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProvenance(value: unknown): TaskChangeProvenance | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const candidate = value as Partial<TaskChangeProvenance>;
|
||||
if (
|
||||
(candidate.sourceKind !== 'ledger' && candidate.sourceKind !== 'legacy') ||
|
||||
typeof candidate.sourceFingerprint !== 'string' ||
|
||||
candidate.sourceFingerprint.trim() === ''
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const journalStamp = normalizeJournalStamp(candidate.journalStamp);
|
||||
return {
|
||||
sourceKind: candidate.sourceKind,
|
||||
sourceFingerprint: candidate.sourceFingerprint,
|
||||
...(journalStamp ? { journalStamp } : {}),
|
||||
...(Number.isFinite(candidate.bundleSchemaVersion)
|
||||
? { bundleSchemaVersion: Number(candidate.bundleSchemaVersion) }
|
||||
: {}),
|
||||
...(candidate.integrity === 'ok' ||
|
||||
candidate.integrity === 'recovered' ||
|
||||
candidate.integrity === 'partial'
|
||||
? { integrity: candidate.integrity }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSummary(
|
||||
value: unknown,
|
||||
teamName: string,
|
||||
|
|
@ -48,6 +144,16 @@ function normalizeSummary(
|
|||
? candidate.confidence
|
||||
: null;
|
||||
const computedAt = normalizeIsoString(candidate.computedAt);
|
||||
const reviewDiagnostics = Array.isArray(candidate.reviewDiagnostics)
|
||||
? candidate.reviewDiagnostics
|
||||
.map(normalizeReviewDiagnostic)
|
||||
.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => diagnostic !== null)
|
||||
: undefined;
|
||||
const diffStatCompleteness =
|
||||
candidate.diffStatCompleteness === 'complete' || candidate.diffStatCompleteness === 'partial'
|
||||
? candidate.diffStatCompleteness
|
||||
: undefined;
|
||||
const provenance = normalizeProvenance(candidate.provenance);
|
||||
if (
|
||||
!files ||
|
||||
!confidence ||
|
||||
|
|
@ -75,6 +181,9 @@ function normalizeSummary(
|
|||
warnings: candidate.warnings.filter(
|
||||
(warning): warning is string => typeof warning === 'string'
|
||||
),
|
||||
...(reviewDiagnostics ? { reviewDiagnostics } : {}),
|
||||
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
||||
...(provenance ? { provenance } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -544,6 +544,127 @@ export async function inspectOpenCodeRuntimeLaneStorage(params: {
|
|||
};
|
||||
}
|
||||
|
||||
export interface OpenCodeRuntimeLaneLaunchGenerationPreparation {
|
||||
reset: boolean;
|
||||
reason:
|
||||
| 'fresh_manifest_created'
|
||||
| 'same_generation_reused'
|
||||
| 'forced_reset'
|
||||
| 'manifest_unreadable'
|
||||
| 'lane_index_terminal'
|
||||
| 'active_run_mismatch'
|
||||
| 'stale_manifest_entries';
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export async function prepareOpenCodeRuntimeLaneForLaunchGeneration(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
runId: string;
|
||||
reason: string;
|
||||
forceReset?: boolean;
|
||||
clock?: () => Date;
|
||||
}): Promise<OpenCodeRuntimeLaneLaunchGenerationPreparation> {
|
||||
const clock = params.clock ?? (() => new Date());
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||
params.teamsBasePath,
|
||||
params.teamName,
|
||||
params.laneId
|
||||
);
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName).catch(
|
||||
() => null
|
||||
);
|
||||
const laneIndexEntry = laneIndex?.lanes[params.laneId] ?? null;
|
||||
const terminalLaneIndex =
|
||||
laneIndexEntry?.state === 'degraded' || laneIndexEntry?.state === 'stopped';
|
||||
|
||||
let manifest: Awaited<ReturnType<typeof readRuntimeStoreManifestEvidenceData>> | null = null;
|
||||
let manifestUnreadable = false;
|
||||
if (await fileExists(manifestPath)) {
|
||||
try {
|
||||
manifest = await readRuntimeStoreManifestEvidenceData(manifestPath, params.teamName, clock);
|
||||
} catch {
|
||||
manifestUnreadable = true;
|
||||
}
|
||||
}
|
||||
|
||||
const staleEntryRunIds =
|
||||
manifest?.entries
|
||||
.filter((entry) => entry.runId !== params.runId)
|
||||
.map((entry) => entry.runId ?? 'none') ?? [];
|
||||
const activeRunMismatch = Boolean(manifest && manifest.activeRunId !== params.runId);
|
||||
const shouldReset =
|
||||
params.forceReset ||
|
||||
manifestUnreadable ||
|
||||
terminalLaneIndex ||
|
||||
activeRunMismatch ||
|
||||
staleEntryRunIds.length > 0;
|
||||
|
||||
let reason: OpenCodeRuntimeLaneLaunchGenerationPreparation['reason'];
|
||||
const diagnostics: string[] = [];
|
||||
if (params.forceReset) {
|
||||
reason = 'forced_reset';
|
||||
diagnostics.push(
|
||||
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: forced reset requested.`
|
||||
);
|
||||
} else if (manifestUnreadable) {
|
||||
reason = 'manifest_unreadable';
|
||||
diagnostics.push(
|
||||
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest could not be read.`
|
||||
);
|
||||
} else if (terminalLaneIndex) {
|
||||
reason = 'lane_index_terminal';
|
||||
diagnostics.push(
|
||||
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: previous lane state was ${laneIndexEntry?.state}.`
|
||||
);
|
||||
} else if (activeRunMismatch) {
|
||||
reason = 'active_run_mismatch';
|
||||
diagnostics.push(
|
||||
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: active run changed from ${manifest?.activeRunId ?? 'none'} to ${params.runId}.`
|
||||
);
|
||||
} else if (staleEntryRunIds.length > 0) {
|
||||
reason = 'stale_manifest_entries';
|
||||
diagnostics.push(
|
||||
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest contained entries from previous run ${Array.from(new Set(staleEntryRunIds)).join(', ')}.`
|
||||
);
|
||||
} else if (!manifest) {
|
||||
reason = 'fresh_manifest_created';
|
||||
diagnostics.push(`Prepared fresh OpenCode runtime lane ${params.laneId} for ${params.reason}.`);
|
||||
} else {
|
||||
reason = 'same_generation_reused';
|
||||
}
|
||||
|
||||
if (shouldReset) {
|
||||
await clearOpenCodeRuntimeLaneStorage({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
});
|
||||
}
|
||||
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
state: 'active',
|
||||
diagnostics: diagnostics.length ? diagnostics : undefined,
|
||||
});
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: params.teamsBasePath,
|
||||
teamName: params.teamName,
|
||||
laneId: params.laneId,
|
||||
runId: params.runId,
|
||||
clock,
|
||||
});
|
||||
|
||||
return {
|
||||
reset: shouldReset,
|
||||
reason,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpenCodeLaneScopedRuntimeFilePath(params: {
|
||||
teamsBasePath: string;
|
||||
teamName: string;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, FileDiff, GitCompareArrows, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { FileIcon } from './editor/FileIcon';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
|
|
@ -59,11 +60,25 @@ function getVisibleFileName(file: FileChangeSummary): string {
|
|||
|
||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||
if (!changeSet) return undefined;
|
||||
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||
if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
|
||||
if (changeSet.warnings.length > 0) return 'attention';
|
||||
if (reviewability === 'attention_required') return 'attention';
|
||||
if (reviewability === 'diagnostic_only') return 'no safe diff';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
||||
const status = classifyTaskChangeReviewability(changeSet);
|
||||
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
||||
return [];
|
||||
}
|
||||
const messages =
|
||||
status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: changeSet.warnings;
|
||||
return [...new Set(messages.filter((message) => message.trim().length > 0))];
|
||||
}
|
||||
|
||||
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||
teamName,
|
||||
tasks,
|
||||
|
|
@ -82,13 +97,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
const visibleSummaries = useMemo(() => {
|
||||
return Object.values(summariesByTaskId)
|
||||
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
|
||||
.filter(
|
||||
(entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } =>
|
||||
.filter((entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => {
|
||||
const changeSet = entry.summary.changeSet;
|
||||
return (
|
||||
Boolean(entry.task) &&
|
||||
(Boolean(entry.summary.error) ||
|
||||
(entry.summary.changeSet?.files.length ?? 0) > 0 ||
|
||||
(entry.summary.changeSet?.warnings.length ?? 0) > 0)
|
||||
)
|
||||
(changeSet?.files.length ?? 0) > 0 ||
|
||||
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
|
||||
}, [summariesByTaskId, taskMap]);
|
||||
|
||||
|
|
@ -163,13 +180,19 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
||||
const changeSet = summary.changeSet;
|
||||
const files = changeSet?.files ?? [];
|
||||
const reviewability = changeSet
|
||||
? classifyTaskChangeReviewability(changeSet).reviewability
|
||||
: 'unknown';
|
||||
const contributors = getTaskChangeContributors(task, changeSet);
|
||||
const contributorLabel =
|
||||
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
|
||||
const extraContributors = Math.max(0, contributors.length - 3);
|
||||
const badgeText = getTaskSummaryBadge(changeSet);
|
||||
const diagnosticMessages = changeSet
|
||||
? getTaskChangeDiagnosticMessages(changeSet)
|
||||
: [];
|
||||
|
||||
if (visibleFiles.length === 0 && !summary.error && !changeSet?.warnings.length) {
|
||||
if (visibleFiles.length === 0 && !summary.error && diagnosticMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -210,15 +233,23 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{changeSet?.warnings.length ? (
|
||||
{diagnosticMessages.length ? (
|
||||
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
|
||||
{changeSet.warnings.slice(0, 2).map((warning) => (
|
||||
{diagnosticMessages.slice(0, 2).map((message) => (
|
||||
<div
|
||||
key={warning}
|
||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
||||
key={message}
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
reviewability === 'attention_required'
|
||||
? 'text-[var(--step-warning-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{reviewability === 'attention_required' ? (
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{warning}</span>
|
||||
) : (
|
||||
<Info size={13} className="shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { withTeamChangesLoadTimeout } from '../teamChangesLoadTimeout';
|
||||
|
||||
describe('withTeamChangesLoadTimeout', () => {
|
||||
it('resolves when the request finishes before the timeout', async () => {
|
||||
await expect(withTeamChangesLoadTimeout(Promise.resolve('ok'), 100)).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('rejects when the request does not finish before the timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const request = withTeamChangesLoadTimeout(new Promise(() => undefined), 1000);
|
||||
const expectation = expect(request).rejects.toThrow('Team changes request timed out');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expectation;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,13 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import {
|
||||
canMemberShowAttachmentControl,
|
||||
getAttachmentInputAcceptForMember,
|
||||
getMemberAttachmentUnavailableReason,
|
||||
validateAttachmentFilesForMember,
|
||||
validateAttachmentPayloadsForMember,
|
||||
} from '@renderer/utils/attachmentRecipientCapabilities';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -147,19 +154,26 @@ export const SendMessageDialog = ({
|
|||
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
|
||||
const memberAttachmentUnavailableReason = showAttachmentControl
|
||||
? getMemberAttachmentUnavailableReason(selectedMember)
|
||||
: null;
|
||||
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
|
||||
const hasTeammates = members.length > 1;
|
||||
const canDelegate = hasTeammates && isLeadRecipient;
|
||||
const shouldAutoDelegate = canDelegate;
|
||||
const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
||||
const supportsAttachments =
|
||||
!!isTeamAlive && showAttachmentControl && memberAttachmentUnavailableReason == null;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentRestrictionReason = !supportsAttachments
|
||||
? !isTeamAlive
|
||||
? 'Team must be online to attach files'
|
||||
: !isLeadRecipient && !isOpenCodeRecipient
|
||||
: !showAttachmentControl
|
||||
? 'Files can be sent to the team lead or OpenCode teammates'
|
||||
: isOpenCodeRecipient
|
||||
: (memberAttachmentUnavailableReason ??
|
||||
(isOpenCodeRecipient
|
||||
? 'Team must be online to attach files for OpenCode teammates'
|
||||
: 'Team must be online to attach files'
|
||||
: 'Team must be online to attach files'))
|
||||
: undefined;
|
||||
|
||||
// Auto-switch to delegate when lead recipient is selected, but don't
|
||||
|
|
@ -257,7 +271,12 @@ export const SendMessageDialog = ({
|
|||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
||||
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
|
||||
member: selectedMember,
|
||||
attachments,
|
||||
});
|
||||
const attachmentsBlocked =
|
||||
attachments.length > 0 && (!supportsAttachments || attachmentPayloadRestrictionReason != null);
|
||||
|
||||
const trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim();
|
||||
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
||||
|
|
@ -313,13 +332,34 @@ export const SendMessageDialog = ({
|
|||
|
||||
const showFileRestrictionError = useCallback(() => {
|
||||
setFileRestrictionError(
|
||||
attachmentRestrictionReason ?? 'Files can be sent to the team lead or OpenCode teammates'
|
||||
attachmentRestrictionReason ??
|
||||
attachmentPayloadRestrictionReason ??
|
||||
'Files can be sent to the team lead or OpenCode teammates'
|
||||
);
|
||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||
setFileRestrictionError(null);
|
||||
}, 4000);
|
||||
}, [attachmentRestrictionReason]);
|
||||
}, [attachmentPayloadRestrictionReason, attachmentRestrictionReason]);
|
||||
|
||||
const validateSelectedAttachmentFiles = useCallback(
|
||||
(files: FileList | File[]): boolean => {
|
||||
const reason = validateAttachmentFilesForMember({
|
||||
member: selectedMember,
|
||||
files,
|
||||
});
|
||||
if (!reason) {
|
||||
return true;
|
||||
}
|
||||
setFileRestrictionError(reason);
|
||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||
setFileRestrictionError(null);
|
||||
}, 4000);
|
||||
return false;
|
||||
},
|
||||
[selectedMember]
|
||||
);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -330,11 +370,15 @@ export const SendMessageDialog = ({
|
|||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!validateSelectedAttachmentFiles(input.files)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
void addFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
},
|
||||
[addFiles, canAttach, showFileRestrictionError]
|
||||
[addFiles, canAttach, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
// Cleanup restriction error timer on unmount
|
||||
|
|
@ -374,9 +418,13 @@ export const SendMessageDialog = ({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length && !validateSelectedAttachmentFiles(files)) {
|
||||
return;
|
||||
}
|
||||
handleDrop(e);
|
||||
},
|
||||
[supportsAttachments, handleDrop, showFileRestrictionError]
|
||||
[supportsAttachments, handleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
const handlePasteWrapper = useCallback(
|
||||
|
|
@ -389,9 +437,17 @@ export const SendMessageDialog = ({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file != null);
|
||||
if (pastedFiles.length > 0 && !validateSelectedAttachmentFiles(pastedFiles)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
handlePaste(e);
|
||||
},
|
||||
[supportsAttachments, handlePaste, showFileRestrictionError]
|
||||
[supportsAttachments, handlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -430,12 +486,12 @@ export const SendMessageDialog = ({
|
|||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="smd-message">Message</Label>
|
||||
{isLeadRecipient ? (
|
||||
{showAttachmentControl ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="*/*"
|
||||
accept={attachmentInputAccept}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
|
|
@ -468,10 +524,14 @@ export const SendMessageDialog = ({
|
|||
<AttachmentPreviewList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError ?? fileRestrictionError}
|
||||
error={attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason}
|
||||
onDismissError={clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient."
|
||||
disabledHint={
|
||||
attachmentPayloadRestrictionReason ??
|
||||
attachmentRestrictionReason ??
|
||||
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
import {
|
||||
deriveTaskDisplayId,
|
||||
formatTaskDisplayLabel,
|
||||
|
|
@ -82,6 +83,7 @@ import {
|
|||
HelpCircle,
|
||||
History,
|
||||
ImageIcon,
|
||||
Info,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
|
|
@ -107,6 +109,7 @@ import type {
|
|||
KanbanTaskState,
|
||||
ResolvedTeamMember,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangeReviewability,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
|
@ -168,6 +171,8 @@ export const TaskDetailDialog = ({
|
|||
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
|
||||
const [taskChangesReviewability, setTaskChangesReviewability] =
|
||||
useState<TaskChangeReviewability | null>(null);
|
||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
|
||||
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
|
||||
|
|
@ -238,6 +243,7 @@ export const TaskDetailDialog = ({
|
|||
setChangesSectionOpen(false);
|
||||
setTaskChangesFiles(null);
|
||||
setTaskChangesWarnings([]);
|
||||
setTaskChangesReviewability(null);
|
||||
setTaskChangesLoading(false);
|
||||
setTaskChangesError(null);
|
||||
setLogsRefreshing(false);
|
||||
|
|
@ -395,7 +401,15 @@ export const TaskDetailDialog = ({
|
|||
const syncTaskChangeSummaryResult = useCallback(
|
||||
(data: TaskChangeSetV2 | null) => {
|
||||
setTaskChangesFiles(data?.files ?? null);
|
||||
setTaskChangesWarnings(data?.warnings ?? []);
|
||||
const status = data ? classifyTaskChangeReviewability(data) : null;
|
||||
const diagnosticMessages =
|
||||
status && status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: (data?.warnings ?? []);
|
||||
setTaskChangesWarnings([
|
||||
...new Set(diagnosticMessages.filter((message) => message.trim().length > 0)),
|
||||
]);
|
||||
setTaskChangesReviewability(status?.reviewability ?? null);
|
||||
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
|
||||
if (currentTask && taskChangeRequestOptions) {
|
||||
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
|
||||
|
|
@ -446,6 +460,7 @@ export const TaskDetailDialog = ({
|
|||
if (!preserveFilesOnError) {
|
||||
setTaskChangesFiles(null);
|
||||
setTaskChangesWarnings([]);
|
||||
setTaskChangesReviewability(null);
|
||||
}
|
||||
setTaskChangesError(
|
||||
error instanceof Error ? error.message : 'Failed to load task changes summary'
|
||||
|
|
@ -592,7 +607,11 @@ export const TaskDetailDialog = ({
|
|||
? taskChangesFiles && taskChangesFiles.length > 0
|
||||
? taskChangesFiles.length
|
||||
: taskChangesFiles && taskChangesWarnings.length > 0
|
||||
? taskChangesReviewability === 'attention_required'
|
||||
? 'attention'
|
||||
: taskChangesReviewability === 'diagnostic_only'
|
||||
? 'no safe diff'
|
||||
: undefined
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
|
|
@ -1245,19 +1264,33 @@ export const TaskDetailDialog = ({
|
|||
) : taskChangesFiles ? (
|
||||
<div className="space-y-2">
|
||||
{taskChangesWarnings.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5">
|
||||
<div
|
||||
className={`space-y-1 rounded-md border px-2 py-1.5 ${
|
||||
taskChangesReviewability === 'attention_required'
|
||||
? 'border-amber-500/20 bg-amber-500/10'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{taskChangesWarnings.slice(0, 2).map((warning) => (
|
||||
<div
|
||||
key={warning}
|
||||
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]"
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
taskChangesReviewability === 'attention_required'
|
||||
? 'text-[var(--step-warning-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
>
|
||||
{taskChangesReviewability === 'attention_required' ? (
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
) : (
|
||||
<Info size={13} className="shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
{taskChangesWarnings.length > 2 ? (
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{taskChangesWarnings.length - 2} more warnings
|
||||
{taskChangesWarnings.length - 2} more diagnostics
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1337,7 +1370,11 @@ export const TaskDetailDialog = ({
|
|||
) : changesSectionOpen ? (
|
||||
<p className="text-xs text-[var(--color-text-muted)]">
|
||||
{taskChangesWarnings.length > 0
|
||||
? taskChangesReviewability === 'attention_required'
|
||||
? 'No reviewable file changes recovered'
|
||||
: taskChangesReviewability === 'diagnostic_only'
|
||||
? 'No safe diff available'
|
||||
: 'No file changes recorded yet'
|
||||
: 'No file changes recorded'}
|
||||
</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ import { cn } from '@renderer/lib/utils';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import {
|
||||
canMemberShowAttachmentControl,
|
||||
getAttachmentInputAcceptForMember,
|
||||
getMemberAttachmentUnavailableReason,
|
||||
validateAttachmentFilesForMember,
|
||||
validateAttachmentPayloadsForMember,
|
||||
} from '@renderer/utils/attachmentRecipientCapabilities';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
|
|
@ -288,6 +295,11 @@ export const MessageComposer = ({
|
|||
normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(selectedMember?.model);
|
||||
const isOpenCodeRecipient = selectedProviderId === 'opencode';
|
||||
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
|
||||
const memberAttachmentUnavailableReason = showAttachmentControl
|
||||
? getMemberAttachmentUnavailableReason(selectedMember)
|
||||
: null;
|
||||
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
|
||||
const hasTeammates = members.length > 1;
|
||||
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
|
||||
const shouldAutoDelegate = isLeadRecipient && canDelegate;
|
||||
|
|
@ -343,24 +355,34 @@ export const MessageComposer = ({
|
|||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const supportsAttachments =
|
||||
!isCrossTeam && !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient);
|
||||
!isCrossTeam &&
|
||||
!!isTeamAlive &&
|
||||
showAttachmentControl &&
|
||||
memberAttachmentUnavailableReason == null;
|
||||
const canAttach = supportsAttachments && draft.canAddMore && !sending;
|
||||
const attachmentRestrictionReason = !supportsAttachments
|
||||
? isCrossTeam
|
||||
? 'File attachments are not supported for cross-team messages'
|
||||
: !isTeamAlive
|
||||
? 'Team must be online to attach files'
|
||||
: !isLeadRecipient && !isOpenCodeRecipient
|
||||
: !showAttachmentControl
|
||||
? 'Files can be sent to the team lead or OpenCode teammates'
|
||||
: isOpenCodeRecipient
|
||||
: (memberAttachmentUnavailableReason ??
|
||||
(isOpenCodeRecipient
|
||||
? 'Team must be online to attach files for OpenCode teammates'
|
||||
: 'Team must be online to attach files'
|
||||
: 'Team must be online to attach files'))
|
||||
: sending
|
||||
? 'Wait for current message to finish sending before adding files'
|
||||
: !draft.canAddMore
|
||||
? 'Maximum attachments reached'
|
||||
: undefined;
|
||||
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
|
||||
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
|
||||
member: selectedMember,
|
||||
attachments: draft.attachments,
|
||||
});
|
||||
const attachmentsBlocked =
|
||||
draft.attachments.length > 0 &&
|
||||
(!supportsAttachments || attachmentPayloadRestrictionReason != null);
|
||||
const slashCommandRestrictionReason = standaloneSlashCommand
|
||||
? draft.attachments.length > 0
|
||||
? 'Slash commands require a live team lead and cannot be sent with attachments'
|
||||
|
|
@ -500,13 +522,34 @@ export const MessageComposer = ({
|
|||
|
||||
const showFileRestrictionError = useCallback(() => {
|
||||
setFileRestrictionError(
|
||||
attachmentRestrictionReason ?? 'Files can only be sent to the team lead'
|
||||
attachmentRestrictionReason ??
|
||||
attachmentPayloadRestrictionReason ??
|
||||
'Files can only be sent to the team lead'
|
||||
);
|
||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||
setFileRestrictionError(null);
|
||||
}, 4000);
|
||||
}, [attachmentRestrictionReason]);
|
||||
}, [attachmentPayloadRestrictionReason, attachmentRestrictionReason]);
|
||||
|
||||
const validateSelectedAttachmentFiles = useCallback(
|
||||
(files: FileList | File[]): boolean => {
|
||||
const reason = validateAttachmentFilesForMember({
|
||||
member: selectedMember,
|
||||
files,
|
||||
});
|
||||
if (!reason) {
|
||||
return true;
|
||||
}
|
||||
setFileRestrictionError(reason);
|
||||
window.clearTimeout(fileRestrictionTimerRef.current);
|
||||
fileRestrictionTimerRef.current = window.setTimeout(() => {
|
||||
setFileRestrictionError(null);
|
||||
}, 4000);
|
||||
return false;
|
||||
},
|
||||
[selectedMember]
|
||||
);
|
||||
|
||||
const { addFiles: draftAddFiles } = draft;
|
||||
const handleFileInputChange = useCallback(
|
||||
|
|
@ -518,11 +561,15 @@ export const MessageComposer = ({
|
|||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (!validateSelectedAttachmentFiles(input.files)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
void draftAddFiles(input.files);
|
||||
}
|
||||
input.value = '';
|
||||
},
|
||||
[canAttach, draftAddFiles, showFileRestrictionError]
|
||||
[canAttach, draftAddFiles, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
// Cleanup restriction error timer on unmount
|
||||
|
|
@ -563,9 +610,13 @@ export const MessageComposer = ({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length && !validateSelectedAttachmentFiles(files)) {
|
||||
return;
|
||||
}
|
||||
draftHandleDrop(e);
|
||||
},
|
||||
[canAttach, draftHandleDrop, showFileRestrictionError]
|
||||
[canAttach, draftHandleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
const { handlePaste: draftHandlePaste } = draft;
|
||||
|
|
@ -579,9 +630,17 @@ export const MessageComposer = ({
|
|||
}
|
||||
return;
|
||||
}
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.kind === 'file')
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file != null);
|
||||
if (pastedFiles.length > 0 && !validateSelectedAttachmentFiles(pastedFiles)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draftHandlePaste(e);
|
||||
},
|
||||
[canAttach, draftHandlePaste, showFileRestrictionError]
|
||||
[canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
|
||||
);
|
||||
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
|
|
@ -625,12 +684,12 @@ export const MessageComposer = ({
|
|||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLeadRecipient ? (
|
||||
{showAttachmentControl ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="*/*"
|
||||
accept={attachmentInputAccept}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
|
|
@ -948,10 +1007,16 @@ export const MessageComposer = ({
|
|||
<AttachmentPreviewList
|
||||
attachments={draft.attachments}
|
||||
onRemove={draft.removeAttachment}
|
||||
error={draft.attachmentError ?? fileRestrictionError}
|
||||
error={
|
||||
draft.attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason
|
||||
}
|
||||
onDismissError={draft.clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient."
|
||||
disabledHint={
|
||||
attachmentPayloadRestrictionReason ??
|
||||
attachmentRestrictionReason ??
|
||||
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import {
|
|||
type TaskChangeRequestOptions,
|
||||
} from '@renderer/utils/taskChangeRequest';
|
||||
import { normalizePathForComparison } from '@shared/utils/platformPath';
|
||||
import { AlertTriangle, ChevronDown, Clock, FileSearch, X } from 'lucide-react';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
import { AlertTriangle, ChevronDown, Clock, FileSearch, Info, X } from 'lucide-react';
|
||||
|
||||
import { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
|
||||
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
|
|
@ -75,28 +76,51 @@ const TaskChangesEmptyState = ({
|
|||
}: {
|
||||
changeSet: TaskChangeSetV2 | null;
|
||||
}): React.ReactElement => {
|
||||
const warnings = changeSet?.warnings ?? [];
|
||||
const hasWarnings = warnings.length > 0;
|
||||
const Icon = hasWarnings ? AlertTriangle : FileSearch;
|
||||
const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null;
|
||||
const diagnosticMessages =
|
||||
status && status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: (changeSet?.warnings ?? []);
|
||||
const uniqueMessages = [
|
||||
...new Set(diagnosticMessages.filter((message) => message.trim().length > 0)),
|
||||
];
|
||||
const isAttention = status?.reviewability === 'attention_required';
|
||||
const isDiagnosticOnly = status?.reviewability === 'diagnostic_only';
|
||||
const isNoSafeDiff = isAttention || isDiagnosticOnly;
|
||||
const hasDiagnosticContext = uniqueMessages.length > 0;
|
||||
const Icon = isAttention ? AlertTriangle : hasDiagnosticContext ? Info : FileSearch;
|
||||
const title = isDiagnosticOnly
|
||||
? 'No safe diff available'
|
||||
: isAttention
|
||||
? 'No reviewable file changes'
|
||||
: 'No file changes recorded';
|
||||
const description = isNoSafeDiff
|
||||
? isDiagnosticOnly
|
||||
? 'The task ledger did not expose a safe file diff for this task.'
|
||||
: 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.'
|
||||
: hasDiagnosticContext
|
||||
? 'The task ledger has no file events for this task yet.'
|
||||
: 'The task ledger has no file events for this task.';
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-6">
|
||||
<div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center">
|
||||
<Icon
|
||||
className={cn('mx-auto mb-2 size-5', hasWarnings ? 'text-amber-300' : 'text-text-muted')}
|
||||
className={cn('mx-auto mb-2 size-5', isAttention ? 'text-amber-300' : 'text-text-muted')}
|
||||
/>
|
||||
<div className="text-sm font-medium text-text">
|
||||
{hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-text-muted">
|
||||
{hasWarnings
|
||||
? 'The task ledger did not expose any safe file diff for this task. The diagnostics below explain why.'
|
||||
: 'The task ledger has no file events for this task.'}
|
||||
</p>
|
||||
{warnings.length > 0 && (
|
||||
<div className="mt-3 space-y-1 rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-left text-xs text-amber-200">
|
||||
{warnings.map((warning, index) => (
|
||||
<div key={`${warning}:${index}`}>{warning}</div>
|
||||
<div className="text-sm font-medium text-text">{title}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-text-muted">{description}</p>
|
||||
{uniqueMessages.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-3 space-y-1 rounded border px-3 py-2 text-left text-xs',
|
||||
isAttention
|
||||
? 'border-amber-500/20 bg-amber-500/10 text-amber-200'
|
||||
: 'border-border bg-surface-raised text-text-muted'
|
||||
)}
|
||||
>
|
||||
{uniqueMessages.map((message, index) => (
|
||||
<div key={`${message}:${index}`}>{message}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1179,7 +1203,7 @@ export const ChangeReviewDialog = ({
|
|||
mode === 'task' &&
|
||||
!!taskChangeSet &&
|
||||
(taskChangeSet.provenance?.sourceKind !== 'ledger' ||
|
||||
taskChangeSet.warnings.length > 0 ||
|
||||
classifyTaskChangeReviewability(taskChangeSet).reviewability === 'attention_required' ||
|
||||
taskChangeSet.scope.confidence.tier > 1);
|
||||
|
||||
// Active file for timeline (derived from scroll-spy)
|
||||
|
|
|
|||
19
src/renderer/components/team/teamChangesLoadTimeout.ts
Normal file
19
src/renderer/components/team/teamChangesLoadTimeout.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000;
|
||||
|
||||
export function withTeamChangesLoadTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs = TEAM_CHANGES_LOAD_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error('Team changes request timed out. Refresh to try again.'));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { api } from '@renderer/api';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||
|
||||
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
||||
import {
|
||||
buildTeamChangeRequestPlan,
|
||||
buildTeamChangesTasksFingerprint,
|
||||
|
|
@ -136,7 +137,9 @@ export function useTeamChangesSummaries({
|
|||
activeRequestSeqRef.current = requestSeq;
|
||||
|
||||
try {
|
||||
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
|
||||
const response = await withTeamChangesLoadTimeout(
|
||||
api.review.getTeamTaskChangeSummaries(teamName, plan.requests)
|
||||
);
|
||||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,12 @@ import { structuredPatch } from 'diff';
|
|||
const taskChangesCheckInFlight = new Set<string>();
|
||||
/** Tracks background presence revalidation for optimistic terminal summary hits */
|
||||
const taskChangesPresenceRevalidationInFlight = new Set<string>();
|
||||
/** Rate-limits forced refreshes for cached needs_attention hits */
|
||||
const taskChangesNeedsAttentionRevalidationTs = new Map<string, number>();
|
||||
/** Negative results cached with timestamp — recheck after 30s */
|
||||
const taskChangesNegativeCache = new Map<string, number>();
|
||||
const NEGATIVE_CACHE_TTL = 30_000;
|
||||
const NEEDS_ATTENTION_REVALIDATION_TTL = 30_000;
|
||||
const TASK_CHANGE_WARM_CONCURRENCY = 4;
|
||||
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
|
||||
let latestAgentChangesRequestToken = 0;
|
||||
|
|
@ -1539,13 +1542,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
|
||||
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
|
||||
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
|
||||
if (
|
||||
summaryCacheable &&
|
||||
(cachedPresence === 'has_changes' || cachedPresence === 'needs_attention')
|
||||
) {
|
||||
if (summaryCacheable && cachedPresence === 'has_changes') {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
|
||||
return;
|
||||
}
|
||||
if (summaryCacheable && cachedPresence === 'needs_attention') {
|
||||
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
|
||||
const lastRevalidationMs = taskChangesNeedsAttentionRevalidationTs.get(cacheKey) ?? 0;
|
||||
if (Date.now() - lastRevalidationMs >= NEEDS_ATTENTION_REVALIDATION_TTL) {
|
||||
taskChangesNeedsAttentionRevalidationTs.set(cacheKey, Date.now());
|
||||
void revalidateTaskChangePresence(teamName, taskId, options);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (taskChangesCheckInFlight.has(cacheKey)) return;
|
||||
const negativeTs = taskChangesNegativeCache.get(cacheKey);
|
||||
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
|
||||
|
|
@ -1627,14 +1636,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
summaryOnly: true,
|
||||
});
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(data);
|
||||
if (nextPresence) {
|
||||
set((s) => ({
|
||||
taskChangePresenceByKey: {
|
||||
...s.taskChangePresenceByKey,
|
||||
[cacheKey]: nextPresence,
|
||||
},
|
||||
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
|
||||
s.taskChangePresenceByKey,
|
||||
cacheKey,
|
||||
nextPresence
|
||||
),
|
||||
}));
|
||||
}
|
||||
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
|
||||
taskChangesNegativeCache.delete(cacheKey);
|
||||
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
|
||||
|
|
@ -1673,6 +1681,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
changed = true;
|
||||
}
|
||||
taskChangesNegativeCache.delete(key);
|
||||
taskChangesNeedsAttentionRevalidationTs.delete(key);
|
||||
}
|
||||
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
|
||||
});
|
||||
|
|
|
|||
178
src/renderer/utils/attachmentRecipientCapabilities.ts
Normal file
178
src/renderer/utils/attachmentRecipientCapabilities.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
resolveAgentAttachmentCapability,
|
||||
type AgentAttachmentCapability,
|
||||
} from '@features/agent-attachments/renderer';
|
||||
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
|
||||
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
export interface MemberAttachmentCapabilityResult {
|
||||
capability: AgentAttachmentCapability;
|
||||
providerId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function getMemberProviderId(member: ResolvedTeamMember): string {
|
||||
return (
|
||||
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||
inferTeamProviderIdFromModel(member.model) ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
function isSupportedFileMime(mimeType: string, supported: readonly string[]): boolean {
|
||||
return supported.some((candidate) =>
|
||||
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
|
||||
);
|
||||
}
|
||||
|
||||
function canReceiveAnyAttachment(capability: AgentAttachmentCapability): boolean {
|
||||
return capability.supportsImages || capability.supportsFiles;
|
||||
}
|
||||
|
||||
export function resolveMemberAttachmentCapability(
|
||||
member: ResolvedTeamMember
|
||||
): MemberAttachmentCapabilityResult {
|
||||
const providerId = getMemberProviderId(member);
|
||||
const model = member.model ?? '';
|
||||
return {
|
||||
providerId,
|
||||
model,
|
||||
capability: resolveAgentAttachmentCapability({ providerId, model }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getMemberAttachmentUnavailableReason(
|
||||
member: ResolvedTeamMember | null | undefined
|
||||
): string | null {
|
||||
if (!member) {
|
||||
return 'Select a recipient before attaching files.';
|
||||
}
|
||||
const { capability } = resolveMemberAttachmentCapability(member);
|
||||
if (canReceiveAnyAttachment(capability)) {
|
||||
return null;
|
||||
}
|
||||
return capability.displayText;
|
||||
}
|
||||
|
||||
export function getAttachmentInputAcceptForMember(
|
||||
member: ResolvedTeamMember | null | undefined
|
||||
): string {
|
||||
if (!member) {
|
||||
return '*/*';
|
||||
}
|
||||
const { capability } = resolveMemberAttachmentCapability(member);
|
||||
if (capability.supportsImages && !capability.supportsFiles) {
|
||||
return 'image/png,image/jpeg,image/webp';
|
||||
}
|
||||
return '*/*';
|
||||
}
|
||||
|
||||
export function validateAttachmentFilesForMember(input: {
|
||||
member: ResolvedTeamMember | null | undefined;
|
||||
files: FileList | File[];
|
||||
}): string | null {
|
||||
const member = input.member;
|
||||
if (!member) {
|
||||
return 'Select a recipient before attaching files.';
|
||||
}
|
||||
const files = Array.from(input.files);
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { capability } = resolveMemberAttachmentCapability(member);
|
||||
if (!canReceiveAnyAttachment(capability)) {
|
||||
return capability.displayText;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const category = categorizeFile(file);
|
||||
if (category === 'unsupported') {
|
||||
continue;
|
||||
}
|
||||
if (category === 'image') {
|
||||
if (!capability.supportsImages) {
|
||||
return capability.displayText;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!capability.supportsFiles) {
|
||||
return capability.filesDisplayText;
|
||||
}
|
||||
const mimeType = getEffectiveMimeType(file);
|
||||
if (!isSupportedFileMime(mimeType, capability.supportedFileMimeTypes)) {
|
||||
return 'This file type is not supported by the selected model.';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateAttachmentPayloadsForMember(input: {
|
||||
member: ResolvedTeamMember | null | undefined;
|
||||
attachments: readonly AttachmentPayload[];
|
||||
}): string | null {
|
||||
const member = input.member;
|
||||
if (!member || input.attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const { capability } = resolveMemberAttachmentCapability(member);
|
||||
if (!canReceiveAnyAttachment(capability)) {
|
||||
return capability.displayText;
|
||||
}
|
||||
|
||||
let imageCount = 0;
|
||||
let fileCount = 0;
|
||||
let totalBytes = 0;
|
||||
for (const attachment of input.attachments) {
|
||||
totalBytes += attachment.size;
|
||||
if (isImageMime(attachment.mimeType)) {
|
||||
imageCount += 1;
|
||||
if (!capability.supportsImages) {
|
||||
return capability.displayText;
|
||||
}
|
||||
if (attachment.size > capability.maxBytesPerImage) {
|
||||
return 'Image is too large for the selected model.';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
fileCount += 1;
|
||||
if (!capability.supportsFiles) {
|
||||
return capability.filesDisplayText;
|
||||
}
|
||||
if (!isSupportedFileMime(attachment.mimeType, capability.supportedFileMimeTypes)) {
|
||||
return 'This file type is not supported by the selected model.';
|
||||
}
|
||||
if (attachment.size > capability.maxBytesPerFile) {
|
||||
return 'File is too large for the selected model.';
|
||||
}
|
||||
}
|
||||
|
||||
if (imageCount > capability.maxImages) {
|
||||
return `Maximum ${capability.maxImages} image attachments for this model.`;
|
||||
}
|
||||
if (fileCount > capability.maxFiles) {
|
||||
return `Maximum ${capability.maxFiles} file attachments for this model.`;
|
||||
}
|
||||
if (totalBytes > capability.maxBytesTotal) {
|
||||
return 'Attachments exceed the selected model size limit.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canMemberShowAttachmentControl(
|
||||
member: ResolvedTeamMember | null | undefined
|
||||
): boolean {
|
||||
if (!member) {
|
||||
return false;
|
||||
}
|
||||
const providerId = getMemberProviderId(member);
|
||||
return isLeadMember(member) || providerId === 'opencode';
|
||||
}
|
||||
|
|
@ -31,6 +31,17 @@ const PROOF_WARNING =
|
|||
'OpenCode reply could not be verified. Message was saved to inbox, but no visible reply or task progress proof was found.';
|
||||
const FAILED_WARNING =
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||
const ATTACHMENT_FAILED_WARNING =
|
||||
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.';
|
||||
|
||||
function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean {
|
||||
const normalized = reason?.trim().toLowerCase();
|
||||
return (
|
||||
normalized === 'opencode_attachment_delivery_prepare_failed' ||
|
||||
normalized?.startsWith('attachment_') === true ||
|
||||
normalized?.startsWith('opencode_attachment_delivery_prepare_failed:') === true
|
||||
);
|
||||
}
|
||||
|
||||
function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
|
||||
const normalized = reason?.trim();
|
||||
|
|
@ -69,6 +80,33 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
|
|||
if (normalizedLower === 'non_visible_tool_without_task_progress') {
|
||||
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_model_unsupported') {
|
||||
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_type_unsupported') {
|
||||
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_too_large') {
|
||||
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
|
||||
}
|
||||
if (
|
||||
normalizedLower === 'attachment_artifact_missing' ||
|
||||
normalizedLower === 'attachment_artifact_path_unsafe'
|
||||
) {
|
||||
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_optimization_failed') {
|
||||
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_provider_rejected') {
|
||||
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
|
||||
}
|
||||
if (normalizedLower === 'attachment_runtime_transport_failed') {
|
||||
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
|
||||
}
|
||||
if (normalizedLower.startsWith('opencode_attachment_delivery_prepare_failed:')) {
|
||||
return normalized.slice('opencode_attachment_delivery_prepare_failed:'.length).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -94,12 +132,16 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
|||
}
|
||||
|
||||
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
|
||||
const failureReason =
|
||||
isFailed || isWarning
|
||||
? formatOpenCodeRuntimeDeliveryFailureReason(
|
||||
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
|
||||
)
|
||||
: '';
|
||||
const candidateFailureReason =
|
||||
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0];
|
||||
const mappedFailureReason =
|
||||
isFailed || isWarning ? formatOpenCodeRuntimeDeliveryFailureReason(candidateFailureReason) : '';
|
||||
const failureReason = mappedFailureReason || (isFailed || isWarning ? userVisibleMessage : '');
|
||||
const isAttachmentFailure =
|
||||
isFailed &&
|
||||
(isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.reason) ||
|
||||
isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.diagnostics?.[0]) ||
|
||||
isOpenCodeAttachmentDeliveryFailureReason(candidateFailureReason));
|
||||
const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
|
||||
|
||||
return {
|
||||
|
|
@ -108,6 +150,10 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
|||
? `${PROOF_WARNING} Reason: ${failureReason}`
|
||||
: isWarning
|
||||
? PROOF_WARNING
|
||||
: isAttachmentFailure && failureReason
|
||||
? `${ATTACHMENT_FAILED_WARNING} Reason: ${failureReason}`
|
||||
: isAttachmentFailure
|
||||
? ATTACHMENT_FAILED_WARNING
|
||||
: isFailed && failureReason
|
||||
? `${FAILED_WARNING} Reason: ${failureReason}`
|
||||
: isFailed
|
||||
|
|
|
|||
|
|
@ -132,6 +132,65 @@ export interface TaskChangeSet {
|
|||
computedAt: string;
|
||||
}
|
||||
|
||||
export const TASK_CHANGE_DIAGNOSTIC_CODES = [
|
||||
'multi_scope_no_safe_diff',
|
||||
'active_task_no_edits_yet',
|
||||
'summary_timeout',
|
||||
'summary_reconstructed',
|
||||
'journal_unavailable',
|
||||
'ledger_integrity_recovered',
|
||||
'ledger_integrity_partial',
|
||||
'ledger_freshness_mismatch',
|
||||
'diff_stat_partial',
|
||||
'tool_failed_after_edit',
|
||||
'tool_killed_after_edit',
|
||||
'unsafe_or_untrusted_evidence',
|
||||
'legacy_warning',
|
||||
] as const;
|
||||
|
||||
export type TaskChangeDiagnosticCode = (typeof TASK_CHANGE_DIAGNOSTIC_CODES)[number];
|
||||
|
||||
export type TaskChangeDiagnosticSeverity = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface TaskChangeReviewDiagnostic {
|
||||
code: TaskChangeDiagnosticCode;
|
||||
severity: TaskChangeDiagnosticSeverity;
|
||||
reviewBlocking: boolean;
|
||||
message: string;
|
||||
source?: 'ledger' | 'legacy' | 'summary' | 'runtime';
|
||||
}
|
||||
|
||||
export type TaskChangeReviewability =
|
||||
| 'reviewable'
|
||||
| 'attention_required'
|
||||
| 'diagnostic_only'
|
||||
| 'none'
|
||||
| 'unknown';
|
||||
|
||||
export type TaskChangeReviewAction =
|
||||
| 'review_diff'
|
||||
| 'inspect_diagnostics'
|
||||
| 'wait_or_refresh'
|
||||
| 'nothing';
|
||||
|
||||
export type TaskChangeReviewReasonCode =
|
||||
| 'files_changed'
|
||||
| 'files_changed_with_non_blocking_diagnostics'
|
||||
| 'diagnostic_only'
|
||||
| 'confirmed_no_changes'
|
||||
| 'pending_no_edits_yet'
|
||||
| 'blocking_diagnostics'
|
||||
| 'low_confidence';
|
||||
|
||||
export interface TaskChangeReviewabilityStatus {
|
||||
reviewability: TaskChangeReviewability;
|
||||
reasonCode: TaskChangeReviewReasonCode;
|
||||
userAction: TaskChangeReviewAction;
|
||||
severity: 'success' | 'warning' | 'info' | 'none';
|
||||
message: string;
|
||||
diagnostics: TaskChangeReviewDiagnostic[];
|
||||
}
|
||||
|
||||
/** Краткая статистика для badge */
|
||||
export interface ChangeStats {
|
||||
linesAdded: number;
|
||||
|
|
@ -287,6 +346,7 @@ export interface TaskBoundariesResult {
|
|||
export interface TaskChangeSetV2 extends TaskChangeSet {
|
||||
scope: TaskChangeScope;
|
||||
warnings: string[];
|
||||
reviewDiagnostics?: TaskChangeReviewDiagnostic[];
|
||||
diffStatCompleteness?: 'complete' | 'partial';
|
||||
provenance?: TaskChangeProvenance;
|
||||
}
|
||||
|
|
|
|||
257
src/shared/utils/__tests__/taskChangeReviewability.test.ts
Normal file
257
src/shared/utils/__tests__/taskChangeReviewability.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveTaskChangePresenceFromResult } from '../taskChangePresence';
|
||||
import {
|
||||
classifyTaskChangeReviewability,
|
||||
EMPTY_INTERVAL_NO_EDITS_WARNING,
|
||||
} from '../taskChangeReviewability';
|
||||
|
||||
import type { TaskChangeSetV2 } from '../../types';
|
||||
|
||||
function changeSet(overrides: Partial<TaskChangeSetV2> = {}): TaskChangeSetV2 {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-a',
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'high',
|
||||
computedAt: '2026-05-09T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||
endTimestamp: '2026-05-09T11:10:00.000Z',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 1, label: 'high', reason: 'test' },
|
||||
},
|
||||
warnings: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('taskChangeReviewability', () => {
|
||||
it('treats changed files with non-blocking multi-scope diagnostics as reviewable', () => {
|
||||
const result = changeSet({
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
snippets: [],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
},
|
||||
],
|
||||
totalFiles: 1,
|
||||
totalLinesAdded: 1,
|
||||
warnings: [
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||
],
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('reviewable');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('has_changes');
|
||||
});
|
||||
|
||||
it('classifies warning-only multi-scope notices as diagnostic-only', () => {
|
||||
const result = changeSet({
|
||||
warnings: [
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||
],
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result)).toMatchObject({
|
||||
reviewability: 'diagnostic_only',
|
||||
reasonCode: 'diagnostic_only',
|
||||
userAction: 'inspect_diagnostics',
|
||||
});
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed for unclassified warning-only summaries', () => {
|
||||
const result = changeSet({ warnings: ['Unexpected ledger warning.'] });
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||
});
|
||||
|
||||
it('keeps active no-edit intervals unknown instead of needs attention', () => {
|
||||
const result = changeSet({
|
||||
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
|
||||
scope: {
|
||||
...changeSet().scope,
|
||||
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps active no-edit intervals fail-closed when blocking diagnostics are present', () => {
|
||||
const result = changeSet({
|
||||
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING, 'Task changes scan timed out.'],
|
||||
scope: {
|
||||
...changeSet().scope,
|
||||
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||
});
|
||||
|
||||
it('marks partial ledger evidence as attention required', () => {
|
||||
const result = changeSet({
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'fingerprint',
|
||||
integrity: 'partial',
|
||||
},
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||
});
|
||||
|
||||
it('deduplicates recovered ledger diagnostics from typed diagnostics and provenance', () => {
|
||||
const result = changeSet({
|
||||
reviewDiagnostics: [
|
||||
{
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
source: 'ledger',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'fingerprint',
|
||||
integrity: 'recovered',
|
||||
},
|
||||
});
|
||||
|
||||
const status = classifyTaskChangeReviewability(result);
|
||||
|
||||
expect(status.reviewability).toBe('attention_required');
|
||||
expect(status.diagnostics).toHaveLength(1);
|
||||
expect(status.diagnostics[0]?.code).toBe('ledger_integrity_recovered');
|
||||
});
|
||||
|
||||
it('does not downgrade typed blocking diagnostics when legacy warnings duplicate them', () => {
|
||||
const result = changeSet({
|
||||
reviewDiagnostics: [
|
||||
{
|
||||
code: 'multi_scope_no_safe_diff',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message:
|
||||
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||
source: 'ledger',
|
||||
},
|
||||
],
|
||||
warnings: [
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||
],
|
||||
});
|
||||
|
||||
const status = classifyTaskChangeReviewability(result);
|
||||
|
||||
expect(status.reviewability).toBe('attention_required');
|
||||
expect(status.diagnostics).toHaveLength(1);
|
||||
expect(status.diagnostics[0]?.reviewBlocking).toBe(true);
|
||||
});
|
||||
|
||||
it('upgrades duplicate diagnostics when legacy warnings are more strict', () => {
|
||||
const result = changeSet({
|
||||
reviewDiagnostics: [
|
||||
{
|
||||
code: 'legacy_warning',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message: 'Unexpected ledger warning.',
|
||||
source: 'summary',
|
||||
},
|
||||
],
|
||||
warnings: ['Unexpected ledger warning.'],
|
||||
});
|
||||
|
||||
const status = classifyTaskChangeReviewability(result);
|
||||
|
||||
expect(status.reviewability).toBe('attention_required');
|
||||
expect(status.diagnostics).toHaveLength(1);
|
||||
expect(status.diagnostics[0]).toMatchObject({
|
||||
code: 'legacy_warning',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
source: 'legacy',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when reported files are missing safe review details', () => {
|
||||
const result = changeSet({
|
||||
totalFiles: 2,
|
||||
files: [
|
||||
{
|
||||
filePath: '/repo/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
snippets: [],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
},
|
||||
],
|
||||
totalLinesAdded: 1,
|
||||
});
|
||||
|
||||
const status = classifyTaskChangeReviewability(result);
|
||||
|
||||
expect(status).toMatchObject({
|
||||
reviewability: 'attention_required',
|
||||
reasonCode: 'blocking_diagnostics',
|
||||
userAction: 'review_diff',
|
||||
});
|
||||
expect(status.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: 'unsafe_or_untrusted_evidence',
|
||||
reviewBlocking: true,
|
||||
})
|
||||
);
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
|
||||
});
|
||||
|
||||
it('tolerates malformed cached scope and diagnostic shapes', () => {
|
||||
const result = changeSet({
|
||||
totalFiles: 'not-a-number' as unknown as number,
|
||||
reviewDiagnostics: {} as unknown as TaskChangeSetV2['reviewDiagnostics'],
|
||||
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
|
||||
scope: {
|
||||
taskId: 'task-a',
|
||||
memberName: 'alice',
|
||||
startTimestamp: '2026-05-09T11:00:00.000Z',
|
||||
endTimestamp: '',
|
||||
confidence: { tier: 2, label: 'medium', reason: 'legacy cache fixture' },
|
||||
} as unknown as TaskChangeSetV2['scope'],
|
||||
});
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
|
||||
});
|
||||
|
||||
it('confirms empty high-confidence summaries as no changes', () => {
|
||||
const result = changeSet();
|
||||
|
||||
expect(classifyTaskChangeReviewability(result).reviewability).toBe('none');
|
||||
expect(resolveTaskChangePresenceFromResult(result)).toBe('no_changes');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +1,21 @@
|
|||
import { classifyTaskChangeReviewability } from './taskChangeReviewability';
|
||||
|
||||
import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types';
|
||||
|
||||
const EMPTY_INTERVAL_NO_EDITS_WARNING = 'No file edits found within persisted workIntervals.';
|
||||
|
||||
function isBenignActiveIntervalWithoutFileEdits(
|
||||
data: Pick<TaskChangeSetV2, 'files' | 'warnings' | 'scope'>
|
||||
): boolean {
|
||||
if (data.files.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data.warnings.length !== 1 || data.warnings[0] !== EMPTY_INTERVAL_NO_EDITS_WARNING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(data.scope.startTimestamp) && !data.scope.endTimestamp && data.scope.toolUseIds.length === 0;
|
||||
}
|
||||
|
||||
export function resolveTaskChangePresenceFromResult(
|
||||
data: Pick<TaskChangeSetV2, 'files' | 'confidence' | 'warnings' | 'scope'>
|
||||
data: Pick<TaskChangeSetV2, 'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'> &
|
||||
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>
|
||||
): Exclude<TaskChangePresenceState, 'unknown'> | null {
|
||||
if (data.files.length > 0) {
|
||||
const status = classifyTaskChangeReviewability(data);
|
||||
switch (status.reviewability) {
|
||||
case 'reviewable':
|
||||
return 'has_changes';
|
||||
}
|
||||
|
||||
if (isBenignActiveIntervalWithoutFileEdits(data)) {
|
||||
case 'attention_required':
|
||||
return 'needs_attention';
|
||||
case 'none':
|
||||
return 'no_changes';
|
||||
case 'diagnostic_only':
|
||||
case 'unknown':
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((data.warnings?.length ?? 0) > 0) {
|
||||
return 'needs_attention';
|
||||
}
|
||||
|
||||
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
|
||||
}
|
||||
|
|
|
|||
391
src/shared/utils/taskChangeReviewability.ts
Normal file
391
src/shared/utils/taskChangeReviewability.ts
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types';
|
||||
|
||||
import type {
|
||||
TaskChangeDiagnosticCode,
|
||||
TaskChangeDiagnosticSeverity,
|
||||
TaskChangeReviewabilityStatus,
|
||||
TaskChangeReviewDiagnostic,
|
||||
TaskChangeSetV2,
|
||||
} from '../types';
|
||||
|
||||
export const EMPTY_INTERVAL_NO_EDITS_WARNING =
|
||||
'No file edits found within persisted workIntervals.';
|
||||
|
||||
const MULTI_SCOPE_MESSAGES = [
|
||||
'Task change ledger skipped attribution because multiple task scopes were active.',
|
||||
'Ledger skipped attribution because multiple task scopes were active.',
|
||||
] as const;
|
||||
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
|
||||
|
||||
type ReviewabilityInput = Pick<
|
||||
TaskChangeSetV2,
|
||||
'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'
|
||||
> &
|
||||
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>;
|
||||
|
||||
interface DiagnosticTemplate {
|
||||
code: TaskChangeDiagnosticCode;
|
||||
severity: TaskChangeDiagnosticSeverity;
|
||||
reviewBlocking: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function templateForLegacyWarning(warning: string): DiagnosticTemplate {
|
||||
const trimmed = warning.trim();
|
||||
const normalized = trimmed.toLowerCase();
|
||||
|
||||
if (MULTI_SCOPE_MESSAGES.some((message) => message.toLowerCase() === normalized)) {
|
||||
return {
|
||||
code: 'multi_scope_no_safe_diff',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message:
|
||||
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized === EMPTY_INTERVAL_NO_EDITS_WARNING.toLowerCase()) {
|
||||
return {
|
||||
code: 'active_task_no_edits_yet',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message: 'No file edits have been observed in the active task interval yet.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.includes('timed out')) {
|
||||
return {
|
||||
code: 'summary_timeout',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The changes scan timed out before it could finish.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.includes('fell back to journal reconstruction')) {
|
||||
return {
|
||||
code: 'summary_reconstructed',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message: 'The change summary was reconstructed from the task-change journal.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.includes('journal was unavailable')) {
|
||||
return {
|
||||
code: 'journal_unavailable',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'Detailed ledger entries were unavailable for this task.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.includes('recovered from malformed journal lines')) {
|
||||
return {
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('freshness did not match') ||
|
||||
normalized.includes('partial') ||
|
||||
normalized.includes('integrity')
|
||||
) {
|
||||
return {
|
||||
code: 'ledger_integrity_partial',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger may be incomplete or stale.',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.startsWith('tool ') && normalized.includes(' failed after changing files')) {
|
||||
return {
|
||||
code: 'tool_failed_after_edit',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'A tool failed after changing files.',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.startsWith('background tool ') &&
|
||||
normalized.includes(' was killed after changing files')
|
||||
) {
|
||||
return {
|
||||
code: 'tool_killed_after_edit',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'A background tool was killed after changing files.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'legacy_warning',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: trimmed || 'The change summary reported an unclassified warning.',
|
||||
};
|
||||
}
|
||||
|
||||
export function createTaskChangeDiagnosticFromWarning(
|
||||
warning: string,
|
||||
source: TaskChangeReviewDiagnostic['source'] = 'legacy'
|
||||
): TaskChangeReviewDiagnostic {
|
||||
const template = templateForLegacyWarning(warning);
|
||||
return { ...template, source };
|
||||
}
|
||||
|
||||
function diagnosticKey(diagnostic: TaskChangeReviewDiagnostic): string {
|
||||
return `${diagnostic.code}:${diagnostic.message}`;
|
||||
}
|
||||
|
||||
function diagnosticSeverityRank(severity: TaskChangeDiagnosticSeverity): number {
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return 3;
|
||||
case 'warning':
|
||||
return 2;
|
||||
case 'info':
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTaskChangeReviewDiagnostics(
|
||||
existing: TaskChangeReviewDiagnostic,
|
||||
incoming: TaskChangeReviewDiagnostic
|
||||
): TaskChangeReviewDiagnostic {
|
||||
if (
|
||||
incoming.reviewBlocking &&
|
||||
(!existing.reviewBlocking ||
|
||||
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity))
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
if (
|
||||
existing.reviewBlocking === incoming.reviewBlocking &&
|
||||
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity)
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
function addDiagnostic(
|
||||
diagnostics: Map<string, TaskChangeReviewDiagnostic>,
|
||||
diagnostic: TaskChangeReviewDiagnostic
|
||||
): void {
|
||||
const key = diagnosticKey(diagnostic);
|
||||
const existing = diagnostics.get(key);
|
||||
if (existing) {
|
||||
diagnostics.set(key, mergeTaskChangeReviewDiagnostics(existing, diagnostic));
|
||||
} else {
|
||||
diagnostics.set(key, diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
function getInputFiles(input: ReviewabilityInput): TaskChangeSetV2['files'] {
|
||||
return Array.isArray(input.files) ? input.files : [];
|
||||
}
|
||||
|
||||
function getInputWarnings(input: ReviewabilityInput): string[] {
|
||||
return Array.isArray(input.warnings)
|
||||
? input.warnings.filter((warning): warning is string => typeof warning === 'string')
|
||||
: [];
|
||||
}
|
||||
|
||||
function getInputReviewDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
||||
if (!Array.isArray(input.reviewDiagnostics)) {
|
||||
return [];
|
||||
}
|
||||
return input.reviewDiagnostics.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => {
|
||||
if (!diagnostic || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
|
||||
return false;
|
||||
}
|
||||
const candidate = diagnostic as Partial<TaskChangeReviewDiagnostic>;
|
||||
return (
|
||||
typeof candidate.code === 'string' &&
|
||||
TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(candidate.code) &&
|
||||
(candidate.severity === 'info' ||
|
||||
candidate.severity === 'warning' ||
|
||||
candidate.severity === 'error') &&
|
||||
typeof candidate.reviewBlocking === 'boolean' &&
|
||||
typeof candidate.message === 'string'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getInputToolUseIds(input: ReviewabilityInput): string[] {
|
||||
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||
return Array.isArray(scope?.toolUseIds) ? scope.toolUseIds : [];
|
||||
}
|
||||
|
||||
function getInputStartTimestamp(input: ReviewabilityInput): string {
|
||||
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||
return typeof scope?.startTimestamp === 'string' ? scope.startTimestamp : '';
|
||||
}
|
||||
|
||||
function getInputEndTimestamp(input: ReviewabilityInput): string {
|
||||
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
|
||||
return typeof scope?.endTimestamp === 'string' ? scope.endTimestamp : '';
|
||||
}
|
||||
|
||||
function getInputTotalFiles(input: ReviewabilityInput, fileCount: number): number {
|
||||
const totalFiles = Number(input.totalFiles);
|
||||
if (!Number.isFinite(totalFiles) || totalFiles < 0) {
|
||||
return fileCount;
|
||||
}
|
||||
return Math.trunc(totalFiles);
|
||||
}
|
||||
|
||||
function collectDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
|
||||
const diagnostics = new Map<string, TaskChangeReviewDiagnostic>();
|
||||
|
||||
for (const diagnostic of getInputReviewDiagnostics(input)) {
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
}
|
||||
|
||||
for (const warning of getInputWarnings(input)) {
|
||||
const diagnostic = createTaskChangeDiagnosticFromWarning(warning);
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
}
|
||||
|
||||
if (input.diffStatCompleteness === 'partial') {
|
||||
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||
code: 'diff_stat_partial',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'Some file change statistics are incomplete.',
|
||||
source: 'summary',
|
||||
};
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
}
|
||||
|
||||
if (input.provenance?.integrity === 'partial') {
|
||||
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||
code: 'ledger_integrity_partial',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger is partially available.',
|
||||
source: 'ledger',
|
||||
};
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
} else if (input.provenance?.integrity === 'recovered') {
|
||||
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||
code: 'ledger_integrity_recovered',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message: 'The task-change ledger was recovered from malformed journal lines.',
|
||||
source: 'ledger',
|
||||
};
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
}
|
||||
|
||||
const fileCount = getInputFiles(input).length;
|
||||
const totalFiles = getInputTotalFiles(input, fileCount);
|
||||
if (totalFiles > fileCount) {
|
||||
const missingFileCount = totalFiles - fileCount;
|
||||
const diagnostic: TaskChangeReviewDiagnostic = {
|
||||
code: 'unsafe_or_untrusted_evidence',
|
||||
severity: 'warning',
|
||||
reviewBlocking: true,
|
||||
message:
|
||||
missingFileCount === 1
|
||||
? 'The change summary reported one file without safe review details.'
|
||||
: `The change summary reported ${missingFileCount} files without safe review details.`,
|
||||
source: 'summary',
|
||||
};
|
||||
addDiagnostic(diagnostics, diagnostic);
|
||||
}
|
||||
|
||||
return [...diagnostics.values()];
|
||||
}
|
||||
|
||||
function isActiveIntervalWithoutFileEdits(
|
||||
input: ReviewabilityInput,
|
||||
diagnostics: TaskChangeReviewDiagnostic[]
|
||||
): boolean {
|
||||
return (
|
||||
getInputFiles(input).length === 0 &&
|
||||
diagnostics.some((diagnostic) => diagnostic.code === 'active_task_no_edits_yet') &&
|
||||
Boolean(getInputStartTimestamp(input)) &&
|
||||
!getInputEndTimestamp(input) &&
|
||||
getInputToolUseIds(input).length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyTaskChangeReviewability(
|
||||
input: ReviewabilityInput
|
||||
): TaskChangeReviewabilityStatus {
|
||||
const diagnostics = collectDiagnostics(input);
|
||||
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.reviewBlocking);
|
||||
const hasFiles = getInputFiles(input).length > 0;
|
||||
|
||||
if (blockingDiagnostics.length > 0) {
|
||||
return {
|
||||
reviewability: 'attention_required',
|
||||
reasonCode: 'blocking_diagnostics',
|
||||
userAction: hasFiles ? 'review_diff' : 'inspect_diagnostics',
|
||||
severity: 'warning',
|
||||
message: hasFiles ? 'Changes may be incomplete.' : 'Changes need attention.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
if (isActiveIntervalWithoutFileEdits(input, diagnostics)) {
|
||||
return {
|
||||
reviewability: 'unknown',
|
||||
reasonCode: 'pending_no_edits_yet',
|
||||
userAction: 'wait_or_refresh',
|
||||
severity: 'none',
|
||||
message: 'No file edits have been observed yet.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasFiles) {
|
||||
return {
|
||||
reviewability: 'reviewable',
|
||||
reasonCode:
|
||||
diagnostics.length > 0 ? 'files_changed_with_non_blocking_diagnostics' : 'files_changed',
|
||||
userAction: 'review_diff',
|
||||
severity: 'success',
|
||||
message: 'Reviewable file changes are available.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
return {
|
||||
reviewability: 'diagnostic_only',
|
||||
reasonCode: 'diagnostic_only',
|
||||
userAction: 'inspect_diagnostics',
|
||||
severity: 'info',
|
||||
message: 'No safe diff is available for this task.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.confidence === 'high' || input.confidence === 'medium') {
|
||||
return {
|
||||
reviewability: 'none',
|
||||
reasonCode: 'confirmed_no_changes',
|
||||
userAction: 'nothing',
|
||||
severity: 'none',
|
||||
message: 'No reviewable file changes were found.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reviewability: 'unknown',
|
||||
reasonCode: 'low_confidence',
|
||||
userAction: 'wait_or_refresh',
|
||||
severity: 'none',
|
||||
message: 'The change summary is not confident enough yet.',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ module.exports = {
|
|||
content: [
|
||||
'./src/renderer/index.html',
|
||||
'./src/renderer/**/*.{js,ts,jsx,tsx}',
|
||||
'./src/features/**/*.{js,ts,jsx,tsx}',
|
||||
'./src/shared/**/*.{js,ts,jsx,tsx}',
|
||||
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
|
||||
],
|
||||
|
|
|
|||
|
|
@ -822,6 +822,9 @@ describe('MemberWorkSync use cases', () => {
|
|||
const inbox = new InMemoryInboxNudge();
|
||||
const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> =
|
||||
[];
|
||||
const busyCalls: Parameters<
|
||||
NonNullable<MemberWorkSyncUseCaseDeps['busySignal']>['isBusy']
|
||||
>[0][] = [];
|
||||
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
|
||||
canDeliver: async () => ({ ok: true }),
|
||||
deliver: async (input) => {
|
||||
|
|
@ -840,6 +843,12 @@ describe('MemberWorkSync use cases', () => {
|
|||
outboxStore: outbox,
|
||||
inboxNudge: inbox,
|
||||
reviewPickupDelivery,
|
||||
busySignal: {
|
||||
isBusy: (input) => {
|
||||
busyCalls.push(input);
|
||||
return Promise.resolve({ busy: false });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await new MemberWorkSyncReconciler(deps).execute(
|
||||
|
|
@ -853,6 +862,15 @@ describe('MemberWorkSync use cases', () => {
|
|||
|
||||
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
|
||||
expect(inbox.inserted).toHaveLength(1);
|
||||
expect(busyCalls).toEqual([
|
||||
{
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
workSyncIntent: 'review_pickup',
|
||||
taskRefs: [{ taskId: 'task-review', displayId: '22222222', teamName: 'team-a' }],
|
||||
},
|
||||
]);
|
||||
expect(deliveryCalls).toHaveLength(1);
|
||||
expect(deliveryCalls[0]).toMatchObject({
|
||||
messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',
|
||||
|
|
|
|||
|
|
@ -368,7 +368,10 @@ function createService(params: {
|
|||
logPaths: string[];
|
||||
projectPath?: string;
|
||||
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
|
||||
taskChangePresenceRepository?: {
|
||||
upsertEntry: ReturnType<typeof vi.fn>;
|
||||
deleteEntry?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
teamLogSourceTracker?: {
|
||||
ensureTracking: ReturnType<
|
||||
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
||||
|
|
@ -1098,24 +1101,70 @@ describe('ChangeExtractorService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('writes needs_attention presence entries for warning-only task diff results', async () => {
|
||||
it('clears cached presence for diagnostic-only multi-scope task diff results', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const upsertEntry = vi.fn(async () => undefined);
|
||||
const ensureTracking = vi.fn(async () => ({
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
}));
|
||||
})
|
||||
);
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(
|
||||
makeTaskChangeResult(TASK_ID, {
|
||||
content: '',
|
||||
confidence: 'fallback',
|
||||
warning: 'Ledger skipped attribution because multiple task scopes were active.',
|
||||
})
|
||||
)
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
'Ledger skipped attribution because multiple task scopes were active.',
|
||||
]);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
||||
});
|
||||
|
||||
it('writes needs_attention presence entries for unclassified warning-only task diff results', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})
|
||||
);
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(
|
||||
makeTaskChangeResult(TASK_ID, {
|
||||
content: '',
|
||||
confidence: 'fallback',
|
||||
warning: 'Unexpected ledger warning.',
|
||||
})
|
||||
)
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
|
|
@ -1128,9 +1177,7 @@ describe('ChangeExtractorService', () => {
|
|||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([
|
||||
'Ledger skipped attribution because multiple task scopes were active.',
|
||||
]);
|
||||
expect(result.warnings).toEqual(['Unexpected ledger warning.']);
|
||||
expect(upsertEntry).toHaveBeenCalledWith(
|
||||
TEAM_NAME,
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
|
|||
|
||||
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
import { resolveTaskChangePresenceFromResult } from '../../../../src/shared/utils/taskChangePresence';
|
||||
|
||||
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
|
||||
|
||||
|
|
@ -96,6 +97,64 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
|
|||
).toContain('"teamName": "team-a"');
|
||||
});
|
||||
|
||||
it('preserves review classification metadata when loading cached entries', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
const repo = new JsonTaskChangeSummaryCacheRepository();
|
||||
|
||||
await repo.save(
|
||||
buildEntry({
|
||||
summary: {
|
||||
...buildEntry().summary,
|
||||
diffStatCompleteness: 'partial',
|
||||
reviewDiagnostics: [
|
||||
{
|
||||
code: 'summary_reconstructed',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message: 'The change summary was reconstructed from the task-change journal.',
|
||||
source: 'summary',
|
||||
},
|
||||
],
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'ledger-fingerprint',
|
||||
integrity: 'partial',
|
||||
bundleSchemaVersion: 2,
|
||||
journalStamp: {
|
||||
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const loaded = await repo.load('team-a', '1');
|
||||
|
||||
expect(loaded?.summary.diffStatCompleteness).toBe('partial');
|
||||
expect(loaded?.summary ? resolveTaskChangePresenceFromResult(loaded.summary) : null).toBe(
|
||||
'needs_attention'
|
||||
);
|
||||
expect(loaded?.summary.reviewDiagnostics).toEqual([
|
||||
{
|
||||
code: 'summary_reconstructed',
|
||||
severity: 'info',
|
||||
reviewBlocking: false,
|
||||
message: 'The change summary was reconstructed from the task-change journal.',
|
||||
source: 'summary',
|
||||
},
|
||||
]);
|
||||
expect(loaded?.summary.provenance).toMatchObject({
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'ledger-fingerprint',
|
||||
integrity: 'partial',
|
||||
bundleSchemaVersion: 2,
|
||||
journalStamp: {
|
||||
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('treats expired entries as cache misses', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -110,7 +169,7 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
|
|||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
const repo = new JsonTaskChangeSummaryCacheRepository();
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, '{bad-json', 'utf8');
|
||||
|
|
|
|||
352
test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts
Normal file
352
test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => os.tmpdir(),
|
||||
getVersion: () => '1.3.0-e2e',
|
||||
isPackaged: false,
|
||||
},
|
||||
BrowserWindow: vi.fn(),
|
||||
dialog: {},
|
||||
ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() },
|
||||
nativeImage: { createFromPath: vi.fn(() => ({})) },
|
||||
net: {},
|
||||
Notification: vi.fn(),
|
||||
safeStorage: {
|
||||
decryptString: vi.fn(),
|
||||
encryptString: vi.fn(),
|
||||
isEncryptionAvailable: vi.fn(() => false),
|
||||
},
|
||||
shell: { openExternal: vi.fn(), showItemInFolder: vi.fn() },
|
||||
}));
|
||||
|
||||
import { createMemberWorkSyncFeature } from '@features/member-work-sync/main';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
|
||||
import { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
|
||||
import { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '@main/services/team/TeamTaskReader';
|
||||
import { TeamTaskWriter } from '@main/services/team/TeamTaskWriter';
|
||||
import {
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '@main/utils/pathDecoder';
|
||||
|
||||
import {
|
||||
createOpenCodeLiveHarness,
|
||||
waitForOpenCodeLanesStopped,
|
||||
} from './openCodeLiveTestHarness';
|
||||
|
||||
const liveDescribe =
|
||||
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_REVIEW_PICKUP === '1'
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
|
||||
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle';
|
||||
|
||||
liveDescribe('OpenCode review pickup live e2e', () => {
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-review-pickup-e2e-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
|
||||
console.info(`[OpenCodeReviewPickup.live] preserved temp dir: ${tempDir}`);
|
||||
} else {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it(
|
||||
'delivers review pickup when the current unread review request is still in the foreground inbox',
|
||||
async () => {
|
||||
const harness = await createOpenCodeLiveHarness({
|
||||
tempDir,
|
||||
selectedModel: MODEL,
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
const feature = createMemberWorkSyncFeature({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
configReader: new TeamConfigReader(),
|
||||
taskReader: new TeamTaskReader(),
|
||||
kanbanManager: new TeamKanbanManager(),
|
||||
membersMetaStore: new TeamMembersMetaStore(),
|
||||
isTeamActive: () => true,
|
||||
queueQuietWindowMs: 0,
|
||||
extraBusySignals: [
|
||||
{
|
||||
isBusy: (input) => harness.svc.getOpenCodeMemberDeliveryBusyStatus(input),
|
||||
},
|
||||
],
|
||||
reviewPickupDelivery: {
|
||||
canDeliver: (input) =>
|
||||
input.providerId === 'opencode'
|
||||
? { ok: true }
|
||||
: {
|
||||
ok: false,
|
||||
reason: `provider_not_supported:${input.providerId ?? 'unknown'}`,
|
||||
},
|
||||
deliver: async (input) => {
|
||||
const relay = await harness.svc.relayOpenCodeMemberInboxMessages(
|
||||
input.teamName,
|
||||
input.memberName,
|
||||
{
|
||||
onlyMessageId: input.messageId,
|
||||
source: 'member-work-sync-review-pickup',
|
||||
deliveryMetadata: {
|
||||
actionMode: input.payload.actionMode,
|
||||
taskRefs: input.payload.taskRefs,
|
||||
},
|
||||
}
|
||||
);
|
||||
const lastDelivery = relay.lastDelivery;
|
||||
const diagnostics = [
|
||||
...(relay.diagnostics ?? []),
|
||||
...(lastDelivery?.diagnostics ?? []),
|
||||
];
|
||||
if (lastDelivery?.accepted === true && lastDelivery.responsePending === true) {
|
||||
return {
|
||||
ok: true,
|
||||
state: 'prompt_accepted' as const,
|
||||
messageId: input.messageId,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
if (lastDelivery?.delivered && lastDelivery.accepted !== false) {
|
||||
return {
|
||||
ok: true,
|
||||
state: lastDelivery.responsePending
|
||||
? ('prompt_accepted' as const)
|
||||
: ('response_proven' as const),
|
||||
messageId: input.messageId,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
lastDelivery?.ledgerStatus === 'failed_terminal'
|
||||
? ('terminal_failure' as const)
|
||||
: ('retryable_failure' as const),
|
||||
message: lastDelivery?.reason ?? 'opencode_review_pickup_delivery_not_confirmed',
|
||||
diagnostics,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamName = `opencode-review-pickup-${Date.now()}`;
|
||||
const memberName = 'bob';
|
||||
const taskId = '7142f765-76e5-4532-8a37-e228b841a6ed';
|
||||
const displayId = '7142f765';
|
||||
|
||||
try {
|
||||
const progressEvents: Array<{ message?: string }> = [];
|
||||
await harness.svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: PROJECT_PATH,
|
||||
providerId: 'opencode',
|
||||
model: MODEL,
|
||||
skipPermissions: true,
|
||||
members: [{ name: memberName, role: 'Reviewer', providerId: 'opencode', model: MODEL }],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
expect(
|
||||
progressEvents.some((progress) =>
|
||||
String(progress.message ?? '').includes('OpenCode team launch is ready')
|
||||
),
|
||||
JSON.stringify(progressEvents, null, 2)
|
||||
).toBe(true);
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
await new TeamTaskWriter().createTask(teamName, {
|
||||
id: taskId,
|
||||
displayId,
|
||||
subject: 'Live review pickup e2e task',
|
||||
description: 'Verify review-pickup delivery over its own unread review request.',
|
||||
owner: 'alice',
|
||||
createdBy: 'lead',
|
||||
status: 'completed',
|
||||
reviewState: 'review',
|
||||
projectPath: PROJECT_PATH,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
|
||||
task.historyEvents = [
|
||||
...(Array.isArray(task.historyEvents) ? task.historyEvents : []),
|
||||
{
|
||||
id: 'evt-live-review-request',
|
||||
type: 'review_requested',
|
||||
timestamp: new Date(Date.now() + 1000).toISOString(),
|
||||
from: 'approved',
|
||||
to: 'review',
|
||||
reviewer: memberName,
|
||||
},
|
||||
];
|
||||
task.updatedAt = new Date().toISOString();
|
||||
await fs.writeFile(taskPath, `${JSON.stringify(task, null, 2)}\n`, 'utf8');
|
||||
|
||||
await new TeamInboxWriter().sendMessage(teamName, {
|
||||
member: memberName,
|
||||
from: 'team-lead',
|
||||
to: memberName,
|
||||
messageId: 'live-review-request-without-taskrefs',
|
||||
source: 'system_notification',
|
||||
summary: `Review request for #${displayId}`,
|
||||
text: [
|
||||
`**Please review** task #${displayId}`,
|
||||
'',
|
||||
'FIRST call review_start to signal you are beginning the review:',
|
||||
`{ teamName: "${teamName}", taskId: "${taskId}", from: "<your-name>" }`,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const status = await feature.refreshStatus({ teamName, memberName });
|
||||
expect(status.state).toBe('needs_sync');
|
||||
expect(status.agenda.items[0]).toMatchObject({
|
||||
taskId,
|
||||
kind: 'review',
|
||||
evidence: {
|
||||
reviewObligation: 'review_pickup_required',
|
||||
reviewRequestEventId: 'evt-live-review-request',
|
||||
},
|
||||
});
|
||||
|
||||
const taskRef = { teamName, taskId, displayId };
|
||||
await expect(
|
||||
harness.svc.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
nowIso: new Date().toISOString(),
|
||||
workSyncIntent: 'review_pickup',
|
||||
taskRefs: [taskRef],
|
||||
})
|
||||
).resolves.toEqual({ busy: false });
|
||||
|
||||
const outboxPath = path.join(
|
||||
getTeamsBasePath(),
|
||||
teamName,
|
||||
'members',
|
||||
memberName,
|
||||
'.member-work-sync',
|
||||
'outbox.json'
|
||||
);
|
||||
|
||||
const reconciledBefore = feature.getQueueDiagnostics().reconciled;
|
||||
feature.noteTeamChange({
|
||||
type: 'member-turn-settled',
|
||||
teamName,
|
||||
detail: JSON.stringify({
|
||||
memberName,
|
||||
sourceId: 'review-pickup-live-e2e',
|
||||
provider: 'opencode',
|
||||
}),
|
||||
});
|
||||
|
||||
await waitForQueueReconciled(feature, reconciledBefore + 1, 45_000);
|
||||
const reviewItem = await waitForReviewPickupOutboxDelivery(outboxPath, 180_000);
|
||||
|
||||
expect(reviewItem).toMatchObject({
|
||||
status: 'delivered',
|
||||
});
|
||||
expect(reviewItem?.lastError).not.toBe('member_busy:opencode_foreground_inbox_unread');
|
||||
} finally {
|
||||
await feature.dispose().catch(() => undefined);
|
||||
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
||||
await harness.dispose().catch(() => undefined);
|
||||
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
|
||||
}
|
||||
},
|
||||
300_000
|
||||
);
|
||||
});
|
||||
|
||||
async function waitForQueueReconciled(
|
||||
feature: ReturnType<typeof createMemberWorkSyncFeature>,
|
||||
expectedReconciled: number,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let diagnostics = feature.getQueueDiagnostics();
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
diagnostics = feature.getQueueDiagnostics();
|
||||
if (diagnostics.reconciled >= expectedReconciled) {
|
||||
return;
|
||||
}
|
||||
if (diagnostics.failed > 0 && diagnostics.queued === 0 && diagnostics.running === 0) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for member-work-sync queue reconcile. Diagnostics: ${JSON.stringify(
|
||||
diagnostics,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForReviewPickupOutboxDelivery(
|
||||
outboxPath: string,
|
||||
timeoutMs: number
|
||||
): Promise<{ status?: string; deliveryState?: string; lastError?: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastOutbox: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const outbox = JSON.parse(await fs.readFile(outboxPath, 'utf8'));
|
||||
lastOutbox = outbox;
|
||||
const reviewItem = Object.values(outbox.items ?? outbox).find(
|
||||
(entry) =>
|
||||
(entry as { payload?: { workSyncIntent?: string } }).payload?.workSyncIntent ===
|
||||
'review_pickup'
|
||||
) as { status?: string; deliveryState?: string; lastError?: string } | undefined;
|
||||
if (reviewItem?.status === 'delivered') {
|
||||
return reviewItem;
|
||||
}
|
||||
if (
|
||||
reviewItem?.status === 'failed_terminal' ||
|
||||
reviewItem?.lastError === 'member_busy:opencode_foreground_inbox_unread'
|
||||
) {
|
||||
throw new Error(`Review pickup failed: ${JSON.stringify(reviewItem, null, 2)}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for review pickup outbox delivery. Last outbox: ${JSON.stringify(
|
||||
lastOutbox,
|
||||
null,
|
||||
2
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getOpenCodeTeamRuntimeDirectory,
|
||||
inspectOpenCodeRuntimeLaneStorage,
|
||||
migrateLegacyOpenCodeRuntimeState,
|
||||
prepareOpenCodeRuntimeLaneForLaunchGeneration,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
recoverStaleOpenCodeRuntimeLaneIndexEntry,
|
||||
|
|
@ -690,3 +691,330 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareOpenCodeRuntimeLaneForLaunchGeneration', () => {
|
||||
let tempDir: string;
|
||||
const teamName = 'team-launch-generation';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const now = new Date('2026-05-09T10:00:00.000Z');
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-generation-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeSessionStoreForRun(runId: string): Promise<void> {
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
(candidate) => candidate.schemaName === 'opencode.sessionStore'
|
||||
);
|
||||
if (!descriptor) throw new Error('session descriptor missing');
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||
const runtimeDirectory = path.dirname(manifestPath);
|
||||
await fs.mkdir(runtimeDirectory, { recursive: true });
|
||||
const writer = new RuntimeStoreBatchWriter(
|
||||
runtimeDirectory,
|
||||
createRuntimeStoreManifestStore({
|
||||
filePath: manifestPath,
|
||||
teamName,
|
||||
clock: () => now,
|
||||
}),
|
||||
createRuntimeStoreReceiptStore({
|
||||
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
||||
}),
|
||||
{
|
||||
clock: () => now,
|
||||
batchIdFactory: () => `batch-${runId}`,
|
||||
receiptIdFactory: () => `receipt-${runId}`,
|
||||
}
|
||||
);
|
||||
await writer.writeBatch({
|
||||
teamName,
|
||||
runId,
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
reason: 'launch_checkpoint',
|
||||
writes: [
|
||||
{
|
||||
descriptor,
|
||||
data: {
|
||||
sessions: [
|
||||
{
|
||||
id: `session-${runId}`,
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
runId,
|
||||
laneId,
|
||||
providerId: 'opencode',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
observedAt: now.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function readManifest() {
|
||||
return createRuntimeStoreManifestStore({
|
||||
filePath: getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId),
|
||||
teamName,
|
||||
}).read();
|
||||
}
|
||||
|
||||
it('creates a fresh active manifest when the lane has no manifest', async () => {
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ reset: false, reason: 'fresh_manifest_created' });
|
||||
});
|
||||
|
||||
it('reuses a same-generation manifest without clearing runtime evidence', async () => {
|
||||
await writeSessionStoreForRun('run-current');
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-current',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-current',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-current',
|
||||
highWatermark: 1,
|
||||
entries: [expect.objectContaining({ runId: 'run-current' })],
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
fileName: 'opencode-sessions.json',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
).resolves.toContain('session-run-current');
|
||||
expect(result).toMatchObject({ reset: false, reason: 'same_generation_reused' });
|
||||
});
|
||||
|
||||
it('resets runtime evidence when activeRunId belongs to an older run', async () => {
|
||||
await writeSessionStoreForRun('run-old');
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-old',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
await expect(
|
||||
fs.readFile(
|
||||
getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
fileName: 'opencode-sessions.json',
|
||||
}),
|
||||
'utf8'
|
||||
)
|
||||
).rejects.toThrow();
|
||||
expect(result).toMatchObject({ reset: true, reason: 'active_run_mismatch' });
|
||||
});
|
||||
|
||||
it('resets when manifest entries belong to an older run even if activeRunId was advanced', async () => {
|
||||
await writeSessionStoreForRun('run-old');
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
|
||||
});
|
||||
|
||||
it('resets entries without a run id because they cannot prove the current generation', async () => {
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
updatedAt: now.toISOString(),
|
||||
data: {
|
||||
schemaVersion: 1,
|
||||
teamName,
|
||||
activeRunId: 'run-new',
|
||||
activeCapabilitySnapshotId: null,
|
||||
activeBehaviorFingerprint: null,
|
||||
highWatermark: 1,
|
||||
lastCommittedBatchId: null,
|
||||
lastPreparingBatchId: null,
|
||||
entries: [
|
||||
{
|
||||
schemaName: 'opencode.runtimeDiagnostics',
|
||||
schemaVersion: 1,
|
||||
relativePath: 'opencode-diagnostics.json',
|
||||
contentHash: null,
|
||||
fileSize: null,
|
||||
mtimeMs: null,
|
||||
runId: null,
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
lastWriteReceiptId: null,
|
||||
state: 'healthy',
|
||||
},
|
||||
],
|
||||
lastRecoveryPlanId: null,
|
||||
updatedAt: now.toISOString(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
|
||||
});
|
||||
|
||||
it('resets unreadable manifests safely', async () => {
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
|
||||
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
await fs.writeFile(manifestPath, '{not-json', 'utf8');
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-new',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-new',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
expect(result).toMatchObject({ reset: true, reason: 'manifest_unreadable' });
|
||||
});
|
||||
|
||||
it('resets degraded or stopped lane index state before launch', async () => {
|
||||
await writeSessionStoreForRun('run-current');
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-current',
|
||||
clock: () => now,
|
||||
});
|
||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
state: 'degraded',
|
||||
diagnostics: ['previous launch failed'],
|
||||
});
|
||||
|
||||
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
|
||||
teamsBasePath: tempDir,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'run-current',
|
||||
reason: 'test_launch',
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
await expect(readManifest()).resolves.toMatchObject({
|
||||
activeRunId: 'run-current',
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ reset: true, reason: 'lane_index_terminal' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6638,6 +6638,82 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps OpenCode inbox relay unread and surfaces a clear reason when the model is not vision-capable', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn();
|
||||
await configureOpenCodeBobDeliveryService({
|
||||
svc,
|
||||
sendMessageToMember,
|
||||
memberModel: 'openrouter/z-ai/glm-5.1',
|
||||
});
|
||||
await (svc as any).attachmentStore.saveAttachments('team-a', 'msg-unsupported-image-model', [
|
||||
{
|
||||
id: 'att-unsupported-model',
|
||||
filename: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: 5,
|
||||
data: 'aW1nMQ==',
|
||||
},
|
||||
]);
|
||||
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||
await fsPromises.writeFile(
|
||||
path.join(inboxDir, 'bob.json'),
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
text: 'Review this image.',
|
||||
timestamp: '2026-04-25T10:00:00.000Z',
|
||||
read: false,
|
||||
messageId: 'msg-unsupported-image-model',
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-unsupported-model',
|
||||
filename: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const relay = await svc.relayOpenCodeMemberInboxMessages('team-a', 'bob', {
|
||||
onlyMessageId: 'msg-unsupported-image-model',
|
||||
});
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
relayed: 0,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
reason: 'attachment_model_unsupported',
|
||||
userVisibleImpact: {
|
||||
state: 'error',
|
||||
reasonCode: 'backend_error',
|
||||
message:
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(relay.diagnostics?.join('\n')).toContain(
|
||||
'opencode_attachment_delivery_prepare_failed: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
expect(sendMessageToMember).not.toHaveBeenCalled();
|
||||
const rows = JSON.parse(
|
||||
await fsPromises.readFile(path.join(inboxDir, 'bob.json'), 'utf8')
|
||||
) as Array<{ read?: boolean }>;
|
||||
expect(rows[0]?.read).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn();
|
||||
|
|
@ -12525,7 +12601,7 @@ describe('TeamProvisioningService', () => {
|
|||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
() => {}
|
||||
vi.fn()
|
||||
);
|
||||
|
||||
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
|
||||
|
|
@ -13063,6 +13139,302 @@ describe('TeamProvisioningService', () => {
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('resets stale OpenCode lane manifests before launch and retries exact stale watermark once', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'safe-mixed-opencode-stale-manifest-recovery';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
sessions: [
|
||||
{
|
||||
id: 'old-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
});
|
||||
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||
const runId = String(input.runId);
|
||||
const manifest = await createRuntimeStoreManifestStore({
|
||||
filePath: getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId),
|
||||
teamName,
|
||||
}).read();
|
||||
expect(manifest).toMatchObject({
|
||||
activeRunId: runId,
|
||||
highWatermark: 0,
|
||||
entries: [],
|
||||
});
|
||||
|
||||
if (adapterLaunch.mock.calls.length === 1) {
|
||||
throw new Error('OpenCode bridge failed: Bridge server runtime manifest high watermark is stale');
|
||||
}
|
||||
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId,
|
||||
sessions: [
|
||||
{
|
||||
id: 'fresh-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId,
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
runId,
|
||||
teamName,
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'clean_success',
|
||||
members: {
|
||||
bob: {
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
});
|
||||
|
||||
const { svc } = createSafeLaunchService();
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: adapterLaunch,
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: tempClaudeRoot,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax/m2.5',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const run = (svc as any).runs.get(runId);
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
|
||||
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
});
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('keeps stale OpenCode lane manifest recovery bounded when the bridge stays stale', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'safe-mixed-opencode-stale-manifest-terminal';
|
||||
const laneId = 'secondary:opencode:bob';
|
||||
const staleWatermarkError =
|
||||
'OpenCode bridge failed: Bridge server runtime manifest high watermark is stale';
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
sessions: [
|
||||
{
|
||||
id: 'old-session-bob',
|
||||
teamName,
|
||||
memberName: 'bob',
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
},
|
||||
],
|
||||
});
|
||||
await setOpenCodeRuntimeActiveRunManifest({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName,
|
||||
laneId,
|
||||
runId: 'old-opencode-run',
|
||||
});
|
||||
|
||||
const adapterLaunch = vi.fn(async () => {
|
||||
throw new Error(staleWatermarkError);
|
||||
});
|
||||
const { svc } = createSafeLaunchService();
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: adapterLaunch,
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: tempClaudeRoot,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax/m2.5',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const run = (svc as any).runs.get(runId);
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
});
|
||||
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(staleWatermarkError);
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('does not retry non-stale OpenCode provider launch failures as manifest recovery', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'safe-mixed-opencode-provider-failure-no-stale-retry';
|
||||
const providerError =
|
||||
'OpenCode quota exhausted. This request requires more credits, or fewer max_tokens.';
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||
|
||||
const adapterLaunch = vi.fn(async () => {
|
||||
throw new Error(providerError);
|
||||
});
|
||||
const { svc } = createSafeLaunchService();
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: adapterLaunch,
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: tempClaudeRoot,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'minimax/m2.5',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const run = (svc as any).runs.get(runId);
|
||||
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(1), { timeout: 5_000 });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
expect(publicStatuses.statuses.bob).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
});
|
||||
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(providerError);
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'mixed-opencode-post-launch-config';
|
||||
|
|
|
|||
|
|
@ -2131,7 +2131,7 @@ Messages:
|
|||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
projectPath: '/mock/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
|
|
@ -3127,6 +3127,86 @@ Messages:
|
|||
});
|
||||
});
|
||||
|
||||
it('does not treat the current unread OpenCode review request as busy for review-pickup checks', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
const laneId = 'secondary:opencode:jack';
|
||||
const teamsBasePath = getTeamsBasePath();
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/tmp/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
hoisted.files.set(
|
||||
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
|
||||
JSON.stringify([
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'jack',
|
||||
text: '**Please review** task #task1234\n\nFIRST call review_start.',
|
||||
timestamp: '2026-02-23T17:31:00.000Z',
|
||||
read: false,
|
||||
messageId: 'review-request-1',
|
||||
source: 'system_notification',
|
||||
summary: 'Review request for #task1234',
|
||||
},
|
||||
])
|
||||
);
|
||||
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
canonicalMemberName: 'jack',
|
||||
laneId,
|
||||
})
|
||||
);
|
||||
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockReturnValue(
|
||||
Promise.resolve({
|
||||
version: 1,
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
lanes: {
|
||||
[laneId]: {
|
||||
laneId,
|
||||
state: 'active',
|
||||
updatedAt: '2026-02-23T17:30:00.000Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
|
||||
getActiveForMember: vi.fn(() => Promise.resolve(null)),
|
||||
});
|
||||
|
||||
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
nowIso: '2026-02-23T17:31:10.000Z',
|
||||
workSyncIntent: 'review_pickup',
|
||||
taskRefs: [{ teamName, taskId: 'task-1234', displayId: 'task1234' }],
|
||||
});
|
||||
|
||||
expect(busy).toEqual({ busy: false });
|
||||
|
||||
const mismatchedTaskBusy = await service.getOpenCodeMemberDeliveryBusyStatus({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
nowIso: '2026-02-23T17:31:10.000Z',
|
||||
workSyncIntent: 'review_pickup',
|
||||
taskRefs: [{ teamName, taskId: 'other-task', displayId: 'other' }],
|
||||
});
|
||||
|
||||
expect(mismatchedTaskBusy).toMatchObject({
|
||||
busy: true,
|
||||
reason: 'opencode_foreground_inbox_unread',
|
||||
activeMessageId: 'review-request-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@ async function writeTaskFile(baseDir: string, taskId: string, projectPath: strin
|
|||
|
||||
function createLedgerBackedChangeExtractorService(params: {
|
||||
projectDir: string;
|
||||
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
|
||||
taskChangePresenceRepository?: {
|
||||
upsertEntry: ReturnType<typeof vi.fn>;
|
||||
deleteEntry?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
teamLogSourceTracker?: {
|
||||
ensureTracking: ReturnType<
|
||||
typeof vi.fn<
|
||||
|
|
@ -752,7 +755,7 @@ describe('task change ledger golden fixtures', () => {
|
|||
expect(computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records needs_attention presence from warning-only ledger fixtures', async () => {
|
||||
it('clears cached presence from diagnostic-only warning ledger fixtures', async () => {
|
||||
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
|
||||
cleanups.push(fixture.cleanup);
|
||||
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
|
||||
|
|
@ -762,14 +765,17 @@ describe('task change ledger golden fixtures', () => {
|
|||
setClaudeBasePathOverride(claudeBaseDir);
|
||||
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
|
||||
|
||||
const upsertEntry = vi.fn(async () => undefined);
|
||||
const ensureTracking = vi.fn(async () => ({
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'fixture-project-fingerprint',
|
||||
logSourceGeneration: 'fixture-log-generation',
|
||||
}));
|
||||
})
|
||||
);
|
||||
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
|
||||
projectDir: fixture.projectDir,
|
||||
taskChangePresenceRepository: { upsertEntry },
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
});
|
||||
|
||||
|
|
@ -784,16 +790,7 @@ describe('task change ledger golden fixtures', () => {
|
|||
'Task change ledger skipped attribution because multiple task scopes were active.'
|
||||
);
|
||||
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
|
||||
expect(upsertEntry).toHaveBeenCalledWith(
|
||||
TEAM_NAME,
|
||||
expect.objectContaining({
|
||||
projectFingerprint: 'fixture-project-fingerprint',
|
||||
logSourceGeneration: 'fixture-log-generation',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
taskId: fixture.manifest.taskId,
|
||||
presence: 'needs_attention',
|
||||
})
|
||||
);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, fixture.manifest.taskId);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ describe('changeReviewSlice task changes', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('treats warning-only summaries as needs_attention and rechecks after invalidation', async () => {
|
||||
it('treats diagnostic-only multi-scope summaries as unknown and rechecks after invalidation', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-a';
|
||||
const taskId = 'presence-warning';
|
||||
|
|
@ -328,17 +328,115 @@ describe('changeReviewSlice task changes', () => {
|
|||
|
||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith(
|
||||
teamName,
|
||||
taskId,
|
||||
'needs_attention'
|
||||
);
|
||||
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||
|
||||
store.getState().invalidateTaskChangePresence([cacheKey]);
|
||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats unclassified warning-only summaries as needs_attention', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-a';
|
||||
const taskId = 'presence-unclassified-warning';
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
teamName,
|
||||
taskId,
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'Unknown warning' },
|
||||
},
|
||||
warnings: ['Unexpected ledger warning.'],
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'ledger-warning-only',
|
||||
},
|
||||
});
|
||||
|
||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
taskId,
|
||||
'needs_attention'
|
||||
);
|
||||
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention');
|
||||
});
|
||||
|
||||
it('background revalidates cached needs_attention presence', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-a';
|
||||
const taskId = 'cached-needs-attention';
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||
store.setState({
|
||||
selectedTeamName: teamName,
|
||||
selectedTeamData: {
|
||||
tasks: [{ id: taskId, changePresence: 'needs_attention' }],
|
||||
},
|
||||
taskChangePresenceByKey: { [cacheKey]: 'needs_attention' },
|
||||
});
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
teamName,
|
||||
taskId,
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
|
||||
},
|
||||
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
|
||||
});
|
||||
|
||||
store.getState().invalidateTaskChangePresence([cacheKey]);
|
||||
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.getTaskChanges).toHaveBeenCalledWith(teamName, taskId, {
|
||||
...OPTIONS_A,
|
||||
summaryOnly: true,
|
||||
forceFresh: true,
|
||||
});
|
||||
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
taskId,
|
||||
'needs_attention'
|
||||
);
|
||||
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
teamName,
|
||||
taskId,
|
||||
'unknown'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not raise needs_attention for active interval summaries with no observed file edits yet', async () => {
|
||||
|
|
@ -737,6 +835,40 @@ describe('changeReviewSlice task changes', () => {
|
|||
await warmPromise;
|
||||
});
|
||||
|
||||
it('clears stale no_changes warm cache entries for diagnostic-only summaries', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-a';
|
||||
const taskId = 'warm-diagnostic-only';
|
||||
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
|
||||
store.setState({ taskChangePresenceByKey: { [cacheKey]: 'no_changes' } });
|
||||
hoisted.getTaskChanges.mockResolvedValue({
|
||||
teamName,
|
||||
taskId,
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
totalLinesAdded: 0,
|
||||
totalLinesRemoved: 0,
|
||||
confidence: 'fallback',
|
||||
computedAt: '2026-03-01T12:00:00.000Z',
|
||||
scope: {
|
||||
taskId,
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
|
||||
},
|
||||
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
|
||||
});
|
||||
|
||||
await store.getState().warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]);
|
||||
|
||||
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
|
||||
const store = createSliceStore();
|
||||
const teamName = 'team-revalidate';
|
||||
|
|
|
|||
106
test/renderer/utils/attachmentRecipientCapabilities.test.ts
Normal file
106
test/renderer/utils/attachmentRecipientCapabilities.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getAttachmentInputAcceptForMember,
|
||||
getMemberAttachmentUnavailableReason,
|
||||
validateAttachmentFilesForMember,
|
||||
validateAttachmentPayloadsForMember,
|
||||
} from '../../../src/renderer/utils/attachmentRecipientCapabilities';
|
||||
|
||||
import type { AttachmentPayload, ResolvedTeamMember } from '../../../src/shared/types';
|
||||
|
||||
function member(overrides: Partial<ResolvedTeamMember>): ResolvedTeamMember {
|
||||
return {
|
||||
name: 'bob',
|
||||
status: 'idle',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function file(name: string, type: string, bytes = 12): File {
|
||||
return new File([new Uint8Array(bytes)], name, { type });
|
||||
}
|
||||
|
||||
function payload(overrides: Partial<AttachmentPayload>): AttachmentPayload {
|
||||
return {
|
||||
id: 'att-1',
|
||||
filename: 'diagram.png',
|
||||
mimeType: 'image/png',
|
||||
size: 12,
|
||||
data: 'aW1n',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('attachmentRecipientCapabilities', () => {
|
||||
it('blocks OpenCode non-vision models before file selection or send', () => {
|
||||
const bob = member({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/z-ai/glm-5.1',
|
||||
});
|
||||
|
||||
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBe(
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBe(
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows image picker input for verified OpenCode vision models', () => {
|
||||
const bob = member({
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/moonshotai/kimi-k2.6',
|
||||
});
|
||||
|
||||
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
|
||||
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
|
||||
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBeNull();
|
||||
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBeNull();
|
||||
});
|
||||
|
||||
it('blocks non-image files for image-only providers', () => {
|
||||
const codexLead = member({
|
||||
name: 'lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.5',
|
||||
});
|
||||
|
||||
expect(validateAttachmentFilesForMember({ member: codexLead, files: [file('notes.md', 'text/markdown')] })).toBe(
|
||||
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
|
||||
);
|
||||
expect(
|
||||
validateAttachmentPayloadsForMember({
|
||||
member: codexLead,
|
||||
attachments: [payload({ filename: 'notes.md', mimeType: 'text/plain' })],
|
||||
})
|
||||
).toBe(
|
||||
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
|
||||
);
|
||||
});
|
||||
|
||||
it('allows text/PDF files for Anthropic lead recipients', () => {
|
||||
const anthropicLead = member({
|
||||
name: 'lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'anthropic',
|
||||
model: 'claude-opus-4-6',
|
||||
});
|
||||
|
||||
expect(validateAttachmentFilesForMember({ member: anthropicLead, files: [file('brief.pdf', 'application/pdf')] })).toBeNull();
|
||||
expect(
|
||||
validateAttachmentPayloadsForMember({
|
||||
member: anthropicLead,
|
||||
attachments: [payload({ filename: 'brief.pdf', mimeType: 'application/pdf' })],
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -193,4 +193,55 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
|||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode created a reply without the required taskRefs metadata.'
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces unsupported OpenCode attachment models as an actionable failure', () => {
|
||||
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'msg-unsupported-attachment-model',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: false,
|
||||
responsePending: false,
|
||||
reason: 'attachment_model_unsupported',
|
||||
diagnostics: [
|
||||
'opencode_attachment_delivery_prepare_failed: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||
],
|
||||
userVisibleImpact: {
|
||||
state: 'error',
|
||||
reasonCode: 'backend_error',
|
||||
message:
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.warning).toBe(
|
||||
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment. Reason: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
expect(diagnostics.debugDetails).toMatchObject({
|
||||
reason: 'attachment_model_unsupported',
|
||||
userVisibleMessage:
|
||||
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps legacy unsupported attachment model codes to an actionable failure', () => {
|
||||
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'msg-legacy-unsupported-attachment-model',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: false,
|
||||
responsePending: false,
|
||||
reason: 'attachment_model_unsupported',
|
||||
diagnostics: ['attachment_model_unsupported'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.warning).toBe(
|
||||
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment. Reason: This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue