From bceef9dec55c26361d17fc890f54b71ced5f5ad9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 17:44:09 +0300 Subject: [PATCH] feat(team): improve review change evidence flow --- landing/product-docs/guide/troubleshooting.md | 51 ++- .../product-docs/ru/guide/troubleshooting.md | 109 +++-- .../agent-attachments/renderer/index.ts | 1 + .../MemberWorkSyncNudgeDispatcher.ts | 2 + .../core/application/ports.ts | 2 + src/main/index.ts | 4 + src/main/ipc/teams.ts | 4 +- .../services/team/ChangeExtractorService.ts | 5 + .../services/team/TaskChangeLedgerReader.ts | 228 +++++++++- .../services/team/TeamProvisioningService.ts | 242 +++++++++-- .../cache/JsonTaskChangePresenceRepository.ts | 32 ++ .../cache/TaskChangePresenceRepository.ts | 1 + .../cache/taskChangeSummaryCacheSchema.ts | 111 ++++- .../OpenCodeRuntimeManifestEvidenceReader.ts | 121 ++++++ .../components/team/TeamChangesSection.tsx | 59 ++- .../__tests__/teamChangesLoadTimeout.test.ts | 23 ++ .../team/dialogs/SendMessageDialog.tsx | 90 +++- .../team/dialogs/TaskDetailDialog.tsx | 51 ++- .../team/messages/MessageComposer.tsx | 95 ++++- .../team/review/ChangeReviewDialog.tsx | 60 ++- .../components/team/teamChangesLoadTimeout.ts | 19 + .../team/useTeamChangesSummaries.ts | 5 +- .../store/slices/changeReviewSlice.ts | 33 +- .../utils/attachmentRecipientCapabilities.ts | 178 ++++++++ .../openCodeRuntimeDeliveryDiagnostics.ts | 68 ++- src/shared/types/review.ts | 60 +++ .../__tests__/taskChangeReviewability.test.ts | 257 ++++++++++++ src/shared/utils/taskChangePresence.ts | 44 +- src/shared/utils/taskChangeReviewability.ts | 391 ++++++++++++++++++ tailwind.config.js | 1 + .../core/MemberWorkSyncUseCases.test.ts | 18 + .../team/ChangeExtractorService.test.ts | 79 +++- ...onTaskChangeSummaryCacheRepository.test.ts | 61 ++- .../OpenCodeReviewPickup.live.tmp.test.ts | 352 ++++++++++++++++ ...nCodeRuntimeManifestEvidenceReader.test.ts | 328 +++++++++++++++ .../team/TeamProvisioningService.test.ts | 374 ++++++++++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 82 +++- ...skChangeLedgerFixtures.integration.test.ts | 35 +- test/renderer/store/changeReviewSlice.test.ts | 138 ++++++- .../attachmentRecipientCapabilities.test.ts | 106 +++++ ...openCodeRuntimeDeliveryDiagnostics.test.ts | 51 +++ 41 files changed, 3721 insertions(+), 250 deletions(-) create mode 100644 src/renderer/components/team/__tests__/teamChangesLoadTimeout.test.ts create mode 100644 src/renderer/components/team/teamChangesLoadTimeout.ts create mode 100644 src/renderer/utils/attachmentRecipientCapabilities.ts create mode 100644 src/shared/utils/__tests__/taskChangeReviewability.test.ts create mode 100644 src/shared/utils/taskChangeReviewability.ts create mode 100644 test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts create mode 100644 test/renderer/utils/attachmentRecipientCapabilities.test.ts diff --git a/landing/product-docs/guide/troubleshooting.md b/landing/product-docs/guide/troubleshooting.md index 835c7223..8d2adfd7 100644 --- a/landing/product-docs/guide/troubleshooting.md +++ b/landing/product-docs/guide/troubleshooting.md @@ -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//` 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//` and correlate UI diagnostics with the live process state before changing code. +::: diff --git a/landing/product-docs/ru/guide/troubleshooting.md b/landing/product-docs/ru/guide/troubleshooting.md index eb1285e4..61c9262b 100644 --- a/landing/product-docs/ru/guide/troubleshooting.md +++ b/landing/product-docs/ru/guide/troubleshooting.md @@ -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//launch-state.json` — состояние member. -3. Посмотрите `~/.claude/teams//.opencode-runtime/lanes//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//launch-failure-artifacts/latest.json +``` + +Манифест внутри включает: + +- `classification` — почему запуск считался неудачным +- `bootstrapTransportBreadcrumb` — использованный путь доставки +- Статусы старта участников +- Редактированные логи и трейсы + +Также проверьте lane manifest: + +```bash +jq '.lanes' ~/.claude/teams//.opencode-runtime/lanes.json +jq '.activeRunId, .entries' ~/.claude/teams//.opencode-runtime/lanes//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//` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью. + +Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении. + +## Lane bootstrap stuck + +Для OpenCode secondary lanes: + +- Отсутствие `inboxes/.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//` на 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. diff --git a/src/features/agent-attachments/renderer/index.ts b/src/features/agent-attachments/renderer/index.ts index 13ed79ed..a01e00f1 100644 --- a/src/features/agent-attachments/renderer/index.ts +++ b/src/features/agent-attachments/renderer/index.ts @@ -1,2 +1,3 @@ export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain'; +export { resolveAgentAttachmentCapability, type AgentAttachmentCapability } from '../core/domain'; export * from './optimizeImageForAgent'; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 789a6840..72ac80e6 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -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 { diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index efbb7d9d..12bf7540 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -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 }>; } diff --git a/src/main/index.ts b/src/main/index.ts index 1516c0d9..cbd4cd2d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 28f4d384..45bf2343 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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 && diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index d625c109..e5a1c8c3 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -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; } diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 3670c56d..3982cee2 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -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, - }), - 'Task change summary fell back to journal reconstruction.', - ], - diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) - ? 'complete' - : 'partial', + warnings, + reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, { + integrity: provenance.integrity, + diffStatCompleteness, + }), + 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[], diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 68dbf237..b5e29d32 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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,31 +25475,66 @@ export class TeamProvisioningService { await finishCancelledLane(); return; } - const rawResult = await adapter.launch({ - runId: lane.runId, - laneId: lane.laneId, - teamName: run.teamName, - cwd: laneCwd, - prompt: appManagedLaunchPrompt, - providerId: 'opencode', - model: lane.member.model, - effort: lane.member.effort, - runtimeOnly: true, - skipPermissions: run.request.skipPermissions !== false, - expectedMembers: [ - { - name: lane.member.name, - role: lane.member.role, - workflow: lane.member.workflow, - isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, - providerId: 'opencode', - model: lane.member.model, - effort: lane.member.effort, - cwd: laneCwd, - }, - ], - previousLaunchState, - }); + const launchOpenCodeLane = () => + adapter.launch({ + runId: laneRunId, + laneId: lane.laneId, + teamName: run.teamName, + cwd: laneCwd, + prompt: appManagedLaunchPrompt, + providerId: 'opencode', + model: lane.member.model, + effort: lane.member.effort, + runtimeOnly: true, + skipPermissions: run.request.skipPermissions !== false, + expectedMembers: [ + { + name: lane.member.name, + role: lane.member.role, + workflow: lane.member.workflow, + isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, + providerId: 'opencode', + model: lane.member.model, + effort: lane.member.effort, + cwd: laneCwd, + }, + ], + 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', diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts index ef271951..3a98d9cd 100644 --- a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -140,4 +140,36 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos this.writeChains.set(teamName, next); await next; } + + async deleteEntry(teamName: string, taskId: string): Promise { + const write = async (): Promise => { + 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; + } } diff --git a/src/main/services/team/cache/TaskChangePresenceRepository.ts b/src/main/services/team/cache/TaskChangePresenceRepository.ts index e07910fa..76e80576 100644 --- a/src/main/services/team/cache/TaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/TaskChangePresenceRepository.ts @@ -20,4 +20,5 @@ export interface TaskChangePresenceRepository { logSourceGeneration: string; } ): Promise; + deleteEntry?(teamName: string, taskId: string): Promise; } diff --git a/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts index 3165d21e..8931f12b 100644 --- a/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts +++ b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts @@ -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(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; + 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; + 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; + 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; + 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 } : {}), }; } diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index 67cf0d67..d650d669 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -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 { + 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> | 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; diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx index 05aec250..b7c92e75 100644 --- a/src/renderer/components/team/TeamChangesSection.tsx +++ b/src/renderer/components/team/TeamChangesSection.tsx @@ -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({ ) : null} - {changeSet?.warnings.length ? ( + {diagnosticMessages.length ? (
- {changeSet.warnings.slice(0, 2).map((warning) => ( + {diagnosticMessages.slice(0, 2).map((message) => (
- - {warning} + {reviewability === 'attention_required' ? ( + + ) : ( + + )} + {message}
))}
diff --git a/src/renderer/components/team/__tests__/teamChangesLoadTimeout.test.ts b/src/renderer/components/team/__tests__/teamChangesLoadTimeout.test.ts new file mode 100644 index 00000000..f7eeb21d --- /dev/null +++ b/src/renderer/components/team/__tests__/teamChangesLoadTimeout.test.ts @@ -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(); + } + }); +}); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 6b4dd2c1..307efcda 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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 - ? 'Team must be online to attach files for OpenCode teammates' - : 'Team must be online to attach files' + : (memberAttachmentUnavailableReason ?? + (isOpenCodeRecipient + ? 'Team must be online to attach files for OpenCode teammates' + : '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) => { @@ -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 = ({
- {isLeadRecipient ? ( + {showAttachmentControl ? ( <>
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 27199db2..3fb6b5dd 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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(null); const [taskChangesWarnings, setTaskChangesWarnings] = useState([]); + const [taskChangesReviewability, setTaskChangesReviewability] = + useState(null); const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesError, setTaskChangesError] = useState(null); const loadedTaskChangeSummaryKeyRef = useRef(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 - ? 'attention' + ? taskChangesReviewability === 'attention_required' + ? 'attention' + : taskChangesReviewability === 'diagnostic_only' + ? 'no safe diff' + : undefined : undefined : undefined; @@ -1245,19 +1264,33 @@ export const TaskDetailDialog = ({ ) : taskChangesFiles ? (
{taskChangesWarnings.length > 0 ? ( -
+
{taskChangesWarnings.slice(0, 2).map((warning) => (
- + {taskChangesReviewability === 'attention_required' ? ( + + ) : ( + + )} {warning}
))} {taskChangesWarnings.length > 2 ? (

- {taskChangesWarnings.length - 2} more warnings + {taskChangesWarnings.length - 2} more diagnostics

) : null}
@@ -1337,7 +1370,11 @@ export const TaskDetailDialog = ({ ) : changesSectionOpen ? (

{taskChangesWarnings.length > 0 - ? 'No reviewable file changes recovered' + ? taskChangesReviewability === 'attention_required' + ? 'No reviewable file changes recovered' + : taskChangesReviewability === 'diagnostic_only' + ? 'No safe diff available' + : 'No file changes recorded yet' : 'No file changes recorded'}

) : null} diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 06a5e2a8..58c647f8 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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 - ? 'Team must be online to attach files for OpenCode teammates' - : 'Team must be online to attach files' + : (memberAttachmentUnavailableReason ?? + (isOpenCodeRecipient + ? 'Team must be online to attach files for OpenCode teammates' + : '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 = ({ )} >
- {isLeadRecipient ? ( + {showAttachmentControl ? ( <> ) : null}
diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 004cdcec..b6498889 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -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 (
-
- {hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'} -
-

- {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.'} -

- {warnings.length > 0 && ( -
- {warnings.map((warning, index) => ( -
{warning}
+
{title}
+

{description}

+ {uniqueMessages.length > 0 && ( +
+ {uniqueMessages.map((message, index) => ( +
{message}
))}
)} @@ -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) diff --git a/src/renderer/components/team/teamChangesLoadTimeout.ts b/src/renderer/components/team/teamChangesLoadTimeout.ts new file mode 100644 index 00000000..abafe862 --- /dev/null +++ b/src/renderer/components/team/teamChangesLoadTimeout.ts @@ -0,0 +1,19 @@ +export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000; + +export function withTeamChangesLoadTimeout( + promise: Promise, + timeoutMs = TEAM_CHANGES_LOAD_TIMEOUT_MS +): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_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); + } + }); +} diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts index 3e008ba5..80f3cd91 100644 --- a/src/renderer/components/team/useTeamChangesSummaries.ts +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -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; } diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 937044e6..48707f6c 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -27,9 +27,12 @@ import { structuredPatch } from 'diff'; const taskChangesCheckInFlight = new Set(); /** Tracks background presence revalidation for optimistic terminal summary hits */ const taskChangesPresenceRevalidationInFlight = new Set(); +/** Rate-limits forced refreshes for cached needs_attention hits */ +const taskChangesNeedsAttentionRevalidationTs = new Map(); /** Negative results cached with timestamp — recheck after 30s */ const taskChangesNegativeCache = new Map(); 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= 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 ({ - taskChangePresenceByKey: { - ...s.taskChangePresenceByKey, - [cacheKey]: nextPresence, - }, - })); - } + set((s) => ({ + 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 + 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'; +} diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 17a0eca0..9925702b 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -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,11 +150,15 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics( ? `${PROOF_WARNING} Reason: ${failureReason}` : isWarning ? PROOF_WARNING - : isFailed && failureReason - ? `${FAILED_WARNING} Reason: ${failureReason}` - : isFailed - ? FAILED_WARNING - : PENDING_WARNING, + : isAttachmentFailure && failureReason + ? `${ATTACHMENT_FAILED_WARNING} Reason: ${failureReason}` + : isAttachmentFailure + ? ATTACHMENT_FAILED_WARNING + : isFailed && failureReason + ? `${FAILED_WARNING} Reason: ${failureReason}` + : isFailed + ? FAILED_WARNING + : PENDING_WARNING, debugDetails: { messageId: result.messageId, statusMessageId, diff --git a/src/shared/types/review.ts b/src/shared/types/review.ts index ad497391..706b2651 100644 --- a/src/shared/types/review.ts +++ b/src/shared/types/review.ts @@ -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; } diff --git a/src/shared/utils/__tests__/taskChangeReviewability.test.ts b/src/shared/utils/__tests__/taskChangeReviewability.test.ts new file mode 100644 index 00000000..e8680311 --- /dev/null +++ b/src/shared/utils/__tests__/taskChangeReviewability.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/shared/utils/taskChangePresence.ts b/src/shared/utils/taskChangePresence.ts index 7a832d96..f64ce2fd 100644 --- a/src/shared/utils/taskChangePresence.ts +++ b/src/shared/utils/taskChangePresence.ts @@ -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 -): 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 + data: Pick & + Partial> ): Exclude | null { - if (data.files.length > 0) { - return 'has_changes'; + const status = classifyTaskChangeReviewability(data); + switch (status.reviewability) { + case 'reviewable': + return 'has_changes'; + case 'attention_required': + return 'needs_attention'; + case 'none': + return 'no_changes'; + case 'diagnostic_only': + case 'unknown': + return null; } - - if (isBenignActiveIntervalWithoutFileEdits(data)) { - return null; - } - - if ((data.warnings?.length ?? 0) > 0) { - return 'needs_attention'; - } - - return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null; } diff --git a/src/shared/utils/taskChangeReviewability.ts b/src/shared/utils/taskChangeReviewability.ts new file mode 100644 index 00000000..13749d21 --- /dev/null +++ b/src/shared/utils/taskChangeReviewability.ts @@ -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(TASK_CHANGE_DIAGNOSTIC_CODES); + +type ReviewabilityInput = Pick< + TaskChangeSetV2, + 'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope' +> & + Partial>; + +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, + 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; + 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 | undefined; + return Array.isArray(scope?.toolUseIds) ? scope.toolUseIds : []; +} + +function getInputStartTimestamp(input: ReviewabilityInput): string { + const scope = input.scope as Partial | undefined; + return typeof scope?.startTimestamp === 'string' ? scope.startTimestamp : ''; +} + +function getInputEndTimestamp(input: ReviewabilityInput): string { + const scope = input.scope as Partial | 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(); + + 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, + }; +} diff --git a/tailwind.config.js b/tailwind.config.js index d28ba4c9..5a0c4388 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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}' ], diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 5374f1a9..717a60a3 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -822,6 +822,9 @@ describe('MemberWorkSync use cases', () => { const inbox = new InMemoryInboxNudge(); const deliveryCalls: Array[0]> = []; + const busyCalls: Parameters< + NonNullable['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', diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 12ef8ad2..b238d687 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -368,7 +368,10 @@ function createService(params: { logPaths: string[]; projectPath?: string; findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise; - taskChangePresenceRepository?: { upsertEntry: ReturnType }; + taskChangePresenceRepository?: { + upsertEntry: ReturnType; + deleteEntry?: ReturnType; + }; 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 () => ({ - projectFingerprint: 'project-fingerprint', - logSourceGeneration: 'log-generation', - })); + 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 () => - makeTaskChangeResult(TASK_ID, { - content: '', - confidence: 'fallback', - warning: 'Ledger skipped attribution because multiple task scopes were active.', - }) + 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({ diff --git a/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts index 70f8dfdb..9649711b 100644 --- a/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts +++ b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts @@ -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'); diff --git a/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts b/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts new file mode 100644 index 00000000..09ba2b66 --- /dev/null +++ b/test/main/services/team/OpenCodeReviewPickup.live.tmp.test.ts @@ -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: "" }`, + ].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, + expectedReconciled: number, + timeoutMs: number +): Promise { + 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 + )}` + ); +} diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts index 6c48631f..4872fe9c 100644 --- a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -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 { + 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' }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 86ef561b..33a76845 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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) => { + 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'; diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 8c1216c6..efeb8d0e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -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'; diff --git a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts index 212a4b35..df386e45 100644 --- a/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts +++ b/test/main/services/team/taskChangeLedgerFixtures.integration.test.ts @@ -47,7 +47,10 @@ async function writeTaskFile(baseDir: string, taskId: string, projectPath: strin function createLedgerBackedChangeExtractorService(params: { projectDir: string; - taskChangePresenceRepository?: { upsertEntry: ReturnType }; + taskChangePresenceRepository?: { + upsertEntry: ReturnType; + deleteEntry?: ReturnType; + }; 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 () => ({ - projectFingerprint: 'fixture-project-fingerprint', - logSourceGeneration: 'fixture-log-generation', - })); + 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); }); }); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 0bc2d397..7b9597d0 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -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'; diff --git a/test/renderer/utils/attachmentRecipientCapabilities.test.ts b/test/renderer/utils/attachmentRecipientCapabilities.test.ts new file mode 100644 index 00000000..5a54dd40 --- /dev/null +++ b/test/renderer/utils/attachmentRecipientCapabilities.test.ts @@ -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 { + 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 { + 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(); + }); +}); diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index 85913182..33f5adef 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -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.' + ); + }); });