feat(team): improve review change evidence flow

This commit is contained in:
777genius 2026-05-09 17:44:09 +03:00
parent 5d3ec8a8bd
commit bceef9dec5
41 changed files with 3721 additions and 250 deletions

View file

@ -1,6 +1,6 @@
# Troubleshooting # 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 ## Team does not launch
@ -12,6 +12,10 @@ Check each item in order:
4. **Project path** — the project directory exists and is readable 4. **Project path** — the project directory exists and is readable
5. **Network / VPN** — some providers drop traffic when a VPN is active 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 ### OpenCode: registered but bootstrap unconfirmed
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts. 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 - 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 - Ensure the key is not expired or revoked in the provider dashboard
### Auth diagnostic log
Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
Check this file if you see "Not logged in" or auth errors in the packaged app.
## Lane bootstrap stuck ## Lane bootstrap stuck
For OpenCode secondary lanes: 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. 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 ## When to collect evidence
Before asking for help, collect: Before asking for help, collect:
@ -107,3 +152,7 @@ Before asking for help, collect:
- Exact time window when the issue occurred - Exact time window when the issue occurred
This data is usually enough to debug launch and task lifecycle issues. This data is usually enough to debug launch and task lifecycle issues.
::: tip
If the issue persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with the live process state before changing code.
:::

View file

@ -1,45 +1,59 @@
# Диагностика # Диагностика
Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing или provider limits. Большинство проблем команды попадает в четыре группы: runtime setup, launch confirmation, task parsing и provider limits.
## Команда не запускается ## Команда не запускается
Проверьте: Проверьте последовательно:
- Выбранный runtime установлен или авторизован 1. **Runtime установлен** — выбранный CLI (`claude`, `codex`, `opencode`) установлен
- Runtime доступен в environment `PATH` 2. **Доступен в PATH** — бинарник доступен в переменной окружения `PATH`
- У провайдера есть доступ к нужной модели 3. **Доступ к модели**у провайдера есть доступ к запрошенной модели (особенно для OpenCode, важны точные имена провайдера и модели)
- Project path существует и читается 4. **Путь к проекту** — директория проекта существует и доступна для чтения
5. **Сеть / VPN** — некоторые провайдеры блокируют трафик при активном VPN
::: tip ::: tip
Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`. Запустите бинарник рантайма в терминале, чтобы проверить PATH и авторизацию. Например: `claude --version` или `opencode --version`.
::: :::
### OpenCode: bootstrap не подтверждён ### OpenCode: registered, но bootstrap не подтверждён
Если OpenCode показывает `registered`, но bootstrap не подтверждён: Если OpenCode показывает `registered`, но bootstrap не подтверждён, сначала inspect artifacts, прежде чем менять team prompts.
1. Откройте launch logs в UI. Посмотрите на последний artifact неудачного запуска:
2. Проверьте `~/.claude/teams/<team>/launch-state.json` — состояние member.
3. Посмотрите `~/.claude/teams/<team>/.opencode-runtime/lanes/<lane-id>/manifest.json` на наличие evidence.
4. Не меняйте team prompts, пока не убедитесь, что lane стартовал, но не смог закоммитить evidence.
::: warning ```bash
Отсутствие OpenCode inbox во время primary launch — норма. Secondary lanes стартуют после готовности primary filesystem. Не считайте primary hang багом OpenCode, пока UI явно не показывает, что `Y` членов ждёт и `Y` некорректно включает OpenCode lanes. ~/.claude/teams/<team>/launch-failure-artifacts/latest.json
```
Манифест внутри включает:
- `classification` — почему запуск считался неудачным
- `bootstrapTransportBreadcrumb` — использованный путь доставки
- Статусы старта участников
- Редактированные логи и трейсы
Также проверьте lane manifest:
```bash
jq '.lanes' ~/.claude/teams/<team>/.opencode-runtime/lanes.json
jq '.activeRunId, .entries' ~/.claude/teams/<team>/.opencode-runtime/lanes/<lane>/manifest.json
```
::: tip Не гадайте по UI
Всегда сопоставляйте UI-диагностику с сохранёнными файлами (`launch-state.json`, `bootstrap-journal.jsonl`) и runtime-специфичными доказательствами.
::: :::
## Не видны ответы агента ## Не видны ответы агента
Откройте task logs и teammate messages. Пропавшие replies часто связаны с: Откройте task logs и teammate messages. Пропавшие replies часто связаны с:
- Runtime delivery gaps - **Runtime delivery retry** — агент мог ответить, но сообщение не доставлено в приложение. Проверьте delivery ledger.
- Parsing или task filtering issues - **Parsing или filtering** — вывод агента не содержал ожидаемых маркеров или task references.
- Агент всё ещё обрабатывает (большие задачи могут занимать минуты) - **Task attribution** — работа выполнялась в рамках сессии, но не была привязана к задаче, потому что в выводе отсутствовал корректный task id.
::: warning Не считайте молчание игнорированием
Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами. Не считайте, что модель проигнорировала сообщение, пока это не подтверждено логами.
::: tip
Для OpenCode teammates проверьте, что вызван `agent-teams_message_send` с правильными `from`, `to` и `taskRefs`. Ответы OpenCode должны отправляться через MCP tools, а не обычным текстом.
::: :::
## Changes не связаны с tasks ## Changes не связаны с tasks
@ -50,9 +64,46 @@
- Убедитесь, что агент вызвал `task_add_comment` перед правками. - Убедитесь, что агент вызвал `task_add_comment` перед правками.
- Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы. - Убедитесь, что агент вызвал `task_start`, чтобы доска знала о начале работы.
Для OpenCode teammates авторитетным доказательством принадлежности сессии к задаче служат `opencode-sessions.json` и запись в lane manifest, а не только UI message stream.
## Rate limits ## Rate limits
Если провайдер сообщает reset time, Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path. Если провайдер сообщает известное время сброса (reset time), Agent Teams может подтолкнуть lead продолжить после cooldown. Если reset time неизвестен, подождите или смените provider/runtime path.
| Поведение провайдера | Рекомендуемое действие |
| --- | --- |
| Отображается известное reset time | Дождитесь cooldown и продолжите |
| Reset time не показан | Смените провайдера или runtime path |
| Повторяющиеся 429 | Снизьте concurrency или используйте другую model lane |
## Проблемы авторизации CLI
### `claude login` не сохраняется
Если CLI авторизован в одном терминале, но приложение говорит, что нет — проверьте, что auth сохранён по ожидаемому пути конфигурации, и что процесс приложения видит тот же `$HOME`.
### OpenCode: ключ провайдера отклонён
- Убедитесь, что имя провайдера в `config.json` совпадает с префиксом провайдера в строке модели
- Проверьте, что ключ не просрочен и не отозван в dashboard провайдера
### Диагностический лог авторизации
Каждый вызов `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
## Lane bootstrap stuck
Для OpenCode secondary lanes:
- Отсутствие `inboxes/<member>.json` автоматически не является багом. OpenCode lanes не обязаны быть созданы через primary inbox перед стартом.
- Если UI показывает, что команда всё ещё запускается, в то время как primary participants уже работоспособны, ожидание «all teammates joined» связано с secondary lanes.
- Если зависает `Prepared communication channels for X/Y members`, проверьте, не включает ли `Y` некорректно secondary OpenCode members.
### Lane manifest empty entries
Если bridge сообщает, что bootstrap успешен, но `manifest.json` показывает `entries: []`, проблема в **evidence commit**, а не в поведении модели. Участник не должен считаться deliverable, пока `opencode-sessions.json` и запись в manifest не существуют.
## Распространённые состояния member ## Распространённые состояния member
@ -81,17 +132,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend. Используйте это для инспекции интерактивного поведения CLI. Не считайте поведение полностью эквивалентным process backend.
## CLI auth diagnostic
Каждый запуск `CliInstallerService.getStatus()` дописывает одну строку в `claude-cli-auth-diag.ndjson` в папке логов Electron (обычно `~/Library/Logs/<product-name>/` на macOS). Если файл превышает **512 KiB**, он обнуляется перед следующей записью.
Проверьте этот файл, если видите «Not logged in» или ошибки авторизации в упакованном приложении.
## Безопасная очистка ## Безопасная очистка
При очистке stale processes: При очистке stale processes:
1. Определите pid и убедитесь, что он принадлежит текущей команде/lane. 1. Определите pid и убедитесь, что он принадлежит текущей команде / lane.
2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch. 2. Останавливайте только процессы, явно принадлежащие smoke test или отлаживаемому launch.
3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut. 3. **Не убивайте** все процессы OpenCode или shared hosts в качестве shortcut.
@ -99,11 +144,11 @@ CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
Соберите: Соберите:
- task id - task id (short или full)
- team name - team name
- runtime path - runtime path (`claude`, `codex`, или `opencode`)
- launch log excerpt - launch log excerpt (из `latest.json` или `bootstrap-journal.jsonl`)
- provider/model - provider / model
- точный time window - точный time window
Этого обычно хватает для диагностики launch и task lifecycle issues. Этого обычно хватает для диагностики launch и task lifecycle issues.

View file

@ -1,2 +1,3 @@
export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain'; export { DEFAULT_AGENT_IMAGE_OPTIMIZATION_BUDGET } from '../core/domain';
export { resolveAgentAttachmentCapability, type AgentAttachmentCapability } from '../core/domain';
export * from './optimizeImageForAgent'; export * from './optimizeImageForAgent';

View file

@ -484,6 +484,8 @@ export class MemberWorkSyncNudgeDispatcher {
teamName: item.teamName, teamName: item.teamName,
memberName: item.memberName, memberName: item.memberName,
nowIso, nowIso,
workSyncIntent: item.payload.workSyncIntent,
taskRefs: item.payload.taskRefs,
}); });
if (busy?.busy) { if (busy?.busy) {
return { return {

View file

@ -188,6 +188,8 @@ export interface MemberWorkSyncBusySignalPort {
teamName: string; teamName: string;
memberName: string; memberName: string;
nowIso: string; nowIso: string;
workSyncIntent?: MemberWorkSyncOutboxItem['payload']['workSyncIntent'];
taskRefs?: MemberWorkSyncOutboxItem['payload']['taskRefs'];
}): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>; }): Promise<{ busy: boolean; reason?: string; retryAfterIso?: string }>;
} }

View file

@ -100,6 +100,7 @@ import {
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics'; import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
import { parseInboxJson } from '@shared/utils/inboxNoise'; import { parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isReviewPickupEscalationMessage } from '@shared/utils/teamAutomationMessages';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages'; import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { app, BrowserWindow, ipcMain } from 'electron'; 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, // Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
// not user-visible conversation messages. // not user-visible conversation messages.
if (isTeamInternalControlMessageEnvelope(msg)) continue; 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.) // Skip internal coordination noise (idle_notification, shutdown_*, etc.)
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue; if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;

View file

@ -3059,7 +3059,9 @@ async function handleSendMessage(
queuedBehindMessageId: delivery.queuedBehindMessageId, queuedBehindMessageId: delivery.queuedBehindMessageId,
reason: delivery.reason, reason: delivery.reason,
diagnostics: delivery.diagnostics, diagnostics: delivery.diagnostics,
userVisibleImpact: provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery), userVisibleImpact:
delivery.userVisibleImpact ??
provisioning.buildOpenCodeRuntimeDeliveryUserVisibleImpact(delivery),
}; };
if ( if (
!delivery.delivered && !delivery.delivered &&

View file

@ -2,6 +2,7 @@ import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiag
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { import {
getTaskChangeStateBucket, getTaskChangeStateBucket,
isTaskChangeSummaryCacheable, isTaskChangeSummaryCacheable,
@ -1542,8 +1543,12 @@ export class ChangeExtractorService {
return; return;
} }
const reviewability = classifyTaskChangeReviewability(result);
const resolvedPresence = resolveTaskChangePresenceFromResult(result); const resolvedPresence = resolveTaskChangePresenceFromResult(result);
if (!resolvedPresence) { if (!resolvedPresence) {
if (reviewability.reviewability === 'diagnostic_only') {
await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
}
return; return;
} }

View file

@ -1,5 +1,9 @@
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath'; import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import {
createTaskChangeDiagnosticFromWarning,
mergeTaskChangeReviewDiagnostics,
} from '@shared/utils/taskChangeReviewability';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { diffLines } from 'diff'; import { diffLines } from 'diff';
import { open, readFile } from 'fs/promises'; import { open, readFile } from 'fs/promises';
@ -14,6 +18,7 @@ import type {
SnippetDiff, SnippetDiff,
TaskChangeJournalStamp, TaskChangeJournalStamp,
TaskChangeProvenance, TaskChangeProvenance,
TaskChangeReviewDiagnostic,
TaskChangeScope, TaskChangeScope,
TaskChangeSetV2, TaskChangeSetV2,
} from '@shared/types'; } from '@shared/types';
@ -847,6 +852,7 @@ export class TaskChangeLedgerReader {
provenance: TaskChangeProvenance; provenance: TaskChangeProvenance;
extraWarnings?: string[]; extraWarnings?: string[];
}): TaskChangeSetV2 { }): TaskChangeSetV2 {
const warnings = [...params.bundle.warnings, ...(params.extraWarnings ?? [])];
return { return {
teamName: params.teamName, teamName: params.teamName,
taskId: params.taskId, taskId: params.taskId,
@ -857,7 +863,14 @@ export class TaskChangeLedgerReader {
confidence: params.bundle.confidence, confidence: params.bundle.confidence,
computedAt: params.bundle.generatedAt, computedAt: params.bundle.generatedAt,
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files), 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, diffStatCompleteness: params.bundle.diffStatCompleteness,
provenance: params.provenance, provenance: params.provenance,
}; };
@ -878,6 +891,13 @@ export class TaskChangeLedgerReader {
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, { const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered, recovered: params.journal.recovered,
}); });
const reviewDiagnostics = this.collectReviewDiagnostics(
projectedEvents,
params.journal.notices,
{
recovered: params.journal.recovered,
}
);
let files: FileChangeSummary[]; let files: FileChangeSummary[];
let totalLinesAdded: number; let totalLinesAdded: number;
@ -930,8 +950,12 @@ export class TaskChangeLedgerReader {
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false) diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete' ? 'complete'
: 'partial'; : 'partial';
warnings.push( const fallbackWarning =
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.' '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(), computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
scope, scope,
warnings, warnings,
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
integrity: params.bundle?.integrity ?? params.provenance.integrity,
diffStatCompleteness,
}),
...(diffStatCompleteness ? { diffStatCompleteness } : {}), ...(diffStatCompleteness ? { diffStatCompleteness } : {}),
provenance: params.provenance, provenance: params.provenance,
}; };
@ -967,6 +995,27 @@ export class TaskChangeLedgerReader {
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null)); const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
const grouped = this.groupSnippets(snippets); const grouped = this.groupSnippets(snippets);
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath); 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 { return {
teamName: params.teamName, teamName: params.teamName,
taskId: params.taskId, taskId: params.taskId,
@ -986,15 +1035,12 @@ export class TaskChangeLedgerReader {
projectedEvents, projectedEvents,
params.journal.notices params.journal.notices
), ),
warnings: [ warnings,
...this.collectWarnings(projectedEvents, params.journal.notices, { reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
recovered: params.journal.recovered, integrity: provenance.integrity,
}), diffStatCompleteness,
'Task change summary fell back to journal reconstruction.', }),
], diffStatCompleteness,
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial',
provenance, provenance,
}; };
} }
@ -1017,6 +1063,10 @@ export class TaskChangeLedgerReader {
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.' '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); 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 { return {
teamName: params.teamName, teamName: params.teamName,
@ -1035,10 +1085,12 @@ export class TaskChangeLedgerReader {
params.bundle.events, params.bundle.events,
params.bundle.notices ?? [] params.bundle.notices ?? []
), ),
warnings: [...warnings], warnings: warningList,
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false) reviewDiagnostics: this.withSummaryStateDiagnostics(
? 'complete' this.buildReviewDiagnosticsFromWarnings(warningList, 'ledger'),
: 'partial', { diffStatCompleteness }
),
diffStatCompleteness,
provenance: { provenance: {
sourceKind: 'ledger', sourceKind: 'ledger',
sourceFingerprint: this.hashFingerprintPayload({ 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( private collectWarnings(
events: LedgerEvent[], events: LedgerEvent[],
notices: LedgerNotice[], notices: LedgerNotice[],

View file

@ -233,6 +233,7 @@ import {
inspectOpenCodeRuntimeLaneStorage, inspectOpenCodeRuntimeLaneStorage,
migrateLegacyOpenCodeRuntimeState, migrateLegacyOpenCodeRuntimeState,
OpenCodeRuntimeManifestEvidenceReader, OpenCodeRuntimeManifestEvidenceReader,
prepareOpenCodeRuntimeLaneForLaunchGeneration,
readCommittedOpenCodeBootstrapSessionEvidence, readCommittedOpenCodeBootstrapSessionEvidence,
readOpenCodeRuntimeLaneIndex, readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry, recoverStaleOpenCodeRuntimeLaneIndexEntry,
@ -9042,10 +9043,19 @@ export class TeamProvisioningService {
? error.code ? error.code
: 'opencode_attachment_delivery_prepare_failed'; : 'opencode_attachment_delivery_prepare_failed';
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`; 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 { return {
delivered: false, delivered: false,
reason, reason,
diagnostics: [diagnostic], diagnostics: [diagnostic],
userVisibleImpact: {
state: 'error',
reasonCode: 'backend_error',
message: userVisibleMessage,
},
}; };
} }
} }
@ -12288,6 +12298,8 @@ export class TeamProvisioningService {
teamName: string; teamName: string;
memberName: string; memberName: string;
nowIso: string; nowIso: string;
workSyncIntent?: 'agenda_sync' | 'review_pickup';
taskRefs?: TaskRef[];
}): Promise<{ }): Promise<{
busy: boolean; busy: boolean;
reason?: string; reason?: string;
@ -12318,7 +12330,10 @@ export class TeamProvisioningService {
const foregroundMessages = inboxMessages.filter( const foregroundMessages = inboxMessages.filter(
(message) => message.messageKind !== 'member_work_sync_nudge' (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) =>
!message.read && !message.read &&
typeof message.text === 'string' && 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); const timestampMs = Date.parse(message.timestamp);
return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000; return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000;
}); });
@ -12441,7 +12456,7 @@ export class TeamProvisioningService {
reasonCode: input.reason reasonCode: input.reason
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason) ? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
: undefined, : undefined,
message: input.reason, message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
}; };
} }
if (input.delivered === false) { if (input.delivered === false) {
@ -12453,18 +12468,84 @@ export class TeamProvisioningService {
return { return {
state: 'checking', state: 'checking',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason), reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason, message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
}; };
} }
return { return {
state: 'error', state: 'error',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason), reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason, message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
}; };
} }
return input.policyImpact ?? { state: 'none' }; 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( private toOpenCodeRuntimeDeliveryStatus(
record: OpenCodePromptDeliveryLedgerRecord, record: OpenCodePromptDeliveryLedgerRecord,
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
@ -21395,8 +21476,9 @@ export class TeamProvisioningService {
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']), ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']),
]; ];
if ( if (
delivery.reason !== 'opencode_runtime_not_active' || !this.isOpenCodeAttachmentDeliveryFailureReason(delivery.reason) &&
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName) (delivery.reason !== 'opencode_runtime_not_active' ||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName))
) { ) {
logger.warn( logger.warn(
`[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${ `[${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; 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 { private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : ''; const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
return from === 'user' || message.source === 'user_sent'; return from === 'user' || message.source === 'user_sent';
@ -25303,12 +25436,13 @@ export class TeamProvisioningService {
lane.state = 'launching'; lane.state = 'launching';
lane.runId = lane.runId ?? randomUUID(); lane.runId = lane.runId ?? randomUUID();
const laneRunId = lane.runId;
lane.warnings = []; lane.warnings = [];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics]; lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
const laneCwd = lane.member.cwd?.trim() || run.request.cwd; const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
this.setSecondaryRuntimeRun({ this.setSecondaryRuntimeRun({
teamName: run.teamName, teamName: run.teamName,
runId: lane.runId, runId: laneRunId,
providerId: 'opencode', providerId: 'opencode',
laneId: lane.laneId, laneId: lane.laneId,
memberName: lane.member.name, memberName: lane.member.name,
@ -25322,11 +25456,12 @@ export class TeamProvisioningService {
await finishCancelledLane(); await finishCancelledLane();
return; return;
} }
await setOpenCodeRuntimeActiveRunManifest({ await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: getTeamsBasePath(), teamsBasePath: getTeamsBasePath(),
teamName: run.teamName, teamName: run.teamName,
laneId: lane.laneId, laneId: lane.laneId,
runId: lane.runId, runId: laneRunId,
reason: 'mixed_secondary_launch',
}); });
if (shouldAbortLaunch()) { if (shouldAbortLaunch()) {
await finishCancelledLane(); await finishCancelledLane();
@ -25340,31 +25475,66 @@ export class TeamProvisioningService {
await finishCancelledLane(); await finishCancelledLane();
return; return;
} }
const rawResult = await adapter.launch({ const launchOpenCodeLane = () =>
runId: lane.runId, adapter.launch({
laneId: lane.laneId, runId: laneRunId,
teamName: run.teamName, laneId: lane.laneId,
cwd: laneCwd, teamName: run.teamName,
prompt: appManagedLaunchPrompt, cwd: laneCwd,
providerId: 'opencode', prompt: appManagedLaunchPrompt,
model: lane.member.model, providerId: 'opencode',
effort: lane.member.effort, model: lane.member.model,
runtimeOnly: true, effort: lane.member.effort,
skipPermissions: run.request.skipPermissions !== false, runtimeOnly: true,
expectedMembers: [ skipPermissions: run.request.skipPermissions !== false,
{ expectedMembers: [
name: lane.member.name, {
role: lane.member.role, name: lane.member.name,
workflow: lane.member.workflow, role: lane.member.role,
isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined, workflow: lane.member.workflow,
providerId: 'opencode', isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
model: lane.member.model, providerId: 'opencode',
effort: lane.member.effort, model: lane.member.model,
cwd: laneCwd, effort: lane.member.effort,
}, cwd: laneCwd,
], },
previousLaunchState, ],
}); 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()) { if (shouldAbortLaunch()) {
await finishCancelledLane(); await finishCancelledLane();
return; return;
@ -25467,7 +25637,7 @@ export class TeamProvisioningService {
lane.launchFinishedAtMs = Date.now(); lane.launchFinishedAtMs = Date.now();
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane); const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
lane.result = { lane.result = {
runId: lane.runId, runId: laneRunId,
teamName: run.teamName, teamName: run.teamName,
launchPhase: 'finished', launchPhase: 'finished',
teamLaunchState: 'partial_failure', teamLaunchState: 'partial_failure',

View file

@ -140,4 +140,36 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos
this.writeChains.set(teamName, next); this.writeChains.set(teamName, next);
await next; await next;
} }
async deleteEntry(teamName: string, taskId: string): Promise<void> {
const write = async (): Promise<void> => {
const current = await this.readIndex(teamName);
if (!current?.entries[taskId]) {
return;
}
const entries = { ...current.entries };
delete entries[taskId];
const next = toPersistedTaskChangePresenceIndex({
...current,
writtenAt: new Date().toISOString(),
entries,
});
await atomicWriteAsync(this.filePath(teamName), JSON.stringify(next, null, 2));
};
const previous = this.writeChains.get(teamName) ?? Promise.resolve();
const next = previous
.catch(() => undefined)
.then(write)
.finally(() => {
if (this.writeChains.get(teamName) === next) {
this.writeChains.delete(teamName);
}
});
this.writeChains.set(teamName, next);
await next;
}
} }

View file

@ -20,4 +20,5 @@ export interface TaskChangePresenceRepository {
logSourceGeneration: string; logSourceGeneration: string;
} }
): Promise<void>; ): Promise<void>;
deleteEntry?(teamName: string, taskId: string): Promise<void>;
} }

View file

@ -1,7 +1,22 @@
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '@shared/types/review';
import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes'; import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes';
import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes'; import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes';
import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types'; import type {
FileChangeSummary,
TaskChangeJournalFileStamp,
TaskChangeJournalStamp,
TaskChangeProvenance,
TaskChangeReviewDiagnostic,
TaskChangeSetV2,
} from '@shared/types';
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
function isTaskChangeDiagnosticCode(value: unknown): value is TaskChangeReviewDiagnostic['code'] {
return typeof value === 'string' && TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(value);
}
function normalizeIsoString(value: unknown): string | null { function normalizeIsoString(value: unknown): string | null {
if (typeof value !== 'string' || value.trim() === '') return null; if (typeof value !== 'string' || value.trim() === '') return null;
@ -31,6 +46,87 @@ function normalizeFileSummary(value: unknown): FileChangeSummary | null {
}; };
} }
function normalizeReviewDiagnostic(value: unknown): TaskChangeReviewDiagnostic | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<TaskChangeReviewDiagnostic>;
if (
!isTaskChangeDiagnosticCode(candidate.code) ||
(candidate.severity !== 'info' &&
candidate.severity !== 'warning' &&
candidate.severity !== 'error') ||
typeof candidate.reviewBlocking !== 'boolean' ||
typeof candidate.message !== 'string'
) {
return null;
}
return {
code: candidate.code,
severity: candidate.severity,
reviewBlocking: candidate.reviewBlocking,
message: candidate.message,
...(candidate.source === 'ledger' ||
candidate.source === 'legacy' ||
candidate.source === 'summary' ||
candidate.source === 'runtime'
? { source: candidate.source }
: {}),
};
}
function normalizeJournalFileStamp(value: unknown): TaskChangeJournalFileStamp | null {
if (!value || typeof value !== 'object') return null;
const candidate = value as Partial<TaskChangeJournalFileStamp>;
if (!Number.isFinite(candidate.bytes) || !Number.isFinite(candidate.mtimeMs)) {
return null;
}
return {
bytes: Number(candidate.bytes),
mtimeMs: Number(candidate.mtimeMs),
tailSha256: typeof candidate.tailSha256 === 'string' ? candidate.tailSha256 : null,
};
}
function normalizeJournalStamp(value: unknown): TaskChangeJournalStamp | undefined {
if (!value || typeof value !== 'object') return undefined;
const candidate = value as Partial<TaskChangeJournalStamp>;
const events = normalizeJournalFileStamp(candidate.events);
const notices = normalizeJournalFileStamp(candidate.notices);
if (!events && !notices) return undefined;
return {
...(events ? { events } : {}),
...(notices ? { notices } : {}),
};
}
function normalizeProvenance(value: unknown): TaskChangeProvenance | undefined {
if (!value || typeof value !== 'object') return undefined;
const candidate = value as Partial<TaskChangeProvenance>;
if (
(candidate.sourceKind !== 'ledger' && candidate.sourceKind !== 'legacy') ||
typeof candidate.sourceFingerprint !== 'string' ||
candidate.sourceFingerprint.trim() === ''
) {
return undefined;
}
const journalStamp = normalizeJournalStamp(candidate.journalStamp);
return {
sourceKind: candidate.sourceKind,
sourceFingerprint: candidate.sourceFingerprint,
...(journalStamp ? { journalStamp } : {}),
...(Number.isFinite(candidate.bundleSchemaVersion)
? { bundleSchemaVersion: Number(candidate.bundleSchemaVersion) }
: {}),
...(candidate.integrity === 'ok' ||
candidate.integrity === 'recovered' ||
candidate.integrity === 'partial'
? { integrity: candidate.integrity }
: {}),
};
}
function normalizeSummary( function normalizeSummary(
value: unknown, value: unknown,
teamName: string, teamName: string,
@ -48,6 +144,16 @@ function normalizeSummary(
? candidate.confidence ? candidate.confidence
: null; : null;
const computedAt = normalizeIsoString(candidate.computedAt); 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 ( if (
!files || !files ||
!confidence || !confidence ||
@ -75,6 +181,9 @@ function normalizeSummary(
warnings: candidate.warnings.filter( warnings: candidate.warnings.filter(
(warning): warning is string => typeof warning === 'string' (warning): warning is string => typeof warning === 'string'
), ),
...(reviewDiagnostics ? { reviewDiagnostics } : {}),
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
...(provenance ? { provenance } : {}),
}; };
} }

View file

@ -544,6 +544,127 @@ export async function inspectOpenCodeRuntimeLaneStorage(params: {
}; };
} }
export interface OpenCodeRuntimeLaneLaunchGenerationPreparation {
reset: boolean;
reason:
| 'fresh_manifest_created'
| 'same_generation_reused'
| 'forced_reset'
| 'manifest_unreadable'
| 'lane_index_terminal'
| 'active_run_mismatch'
| 'stale_manifest_entries';
diagnostics: string[];
}
export async function prepareOpenCodeRuntimeLaneForLaunchGeneration(params: {
teamsBasePath: string;
teamName: string;
laneId: string;
runId: string;
reason: string;
forceReset?: boolean;
clock?: () => Date;
}): Promise<OpenCodeRuntimeLaneLaunchGenerationPreparation> {
const clock = params.clock ?? (() => new Date());
const manifestPath = getOpenCodeRuntimeManifestPath(
params.teamsBasePath,
params.teamName,
params.laneId
);
const laneIndex = await readOpenCodeRuntimeLaneIndex(params.teamsBasePath, params.teamName).catch(
() => null
);
const laneIndexEntry = laneIndex?.lanes[params.laneId] ?? null;
const terminalLaneIndex =
laneIndexEntry?.state === 'degraded' || laneIndexEntry?.state === 'stopped';
let manifest: Awaited<ReturnType<typeof readRuntimeStoreManifestEvidenceData>> | null = null;
let manifestUnreadable = false;
if (await fileExists(manifestPath)) {
try {
manifest = await readRuntimeStoreManifestEvidenceData(manifestPath, params.teamName, clock);
} catch {
manifestUnreadable = true;
}
}
const staleEntryRunIds =
manifest?.entries
.filter((entry) => entry.runId !== params.runId)
.map((entry) => entry.runId ?? 'none') ?? [];
const activeRunMismatch = Boolean(manifest && manifest.activeRunId !== params.runId);
const shouldReset =
params.forceReset ||
manifestUnreadable ||
terminalLaneIndex ||
activeRunMismatch ||
staleEntryRunIds.length > 0;
let reason: OpenCodeRuntimeLaneLaunchGenerationPreparation['reason'];
const diagnostics: string[] = [];
if (params.forceReset) {
reason = 'forced_reset';
diagnostics.push(
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: forced reset requested.`
);
} else if (manifestUnreadable) {
reason = 'manifest_unreadable';
diagnostics.push(
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest could not be read.`
);
} else if (terminalLaneIndex) {
reason = 'lane_index_terminal';
diagnostics.push(
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: previous lane state was ${laneIndexEntry?.state}.`
);
} else if (activeRunMismatch) {
reason = 'active_run_mismatch';
diagnostics.push(
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: active run changed from ${manifest?.activeRunId ?? 'none'} to ${params.runId}.`
);
} else if (staleEntryRunIds.length > 0) {
reason = 'stale_manifest_entries';
diagnostics.push(
`Reset OpenCode runtime lane ${params.laneId} before ${params.reason}: runtime manifest contained entries from previous run ${Array.from(new Set(staleEntryRunIds)).join(', ')}.`
);
} else if (!manifest) {
reason = 'fresh_manifest_created';
diagnostics.push(`Prepared fresh OpenCode runtime lane ${params.laneId} for ${params.reason}.`);
} else {
reason = 'same_generation_reused';
}
if (shouldReset) {
await clearOpenCodeRuntimeLaneStorage({
teamsBasePath: params.teamsBasePath,
teamName: params.teamName,
laneId: params.laneId,
});
}
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: params.teamsBasePath,
teamName: params.teamName,
laneId: params.laneId,
state: 'active',
diagnostics: diagnostics.length ? diagnostics : undefined,
});
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: params.teamsBasePath,
teamName: params.teamName,
laneId: params.laneId,
runId: params.runId,
clock,
});
return {
reset: shouldReset,
reason,
diagnostics,
};
}
export function getOpenCodeLaneScopedRuntimeFilePath(params: { export function getOpenCodeLaneScopedRuntimeFilePath(params: {
teamsBasePath: string; teamsBasePath: string;
teamName: string; teamName: string;

View file

@ -1,8 +1,9 @@
import { memo, useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; 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 { FileIcon } from './editor/FileIcon';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
@ -59,11 +60,25 @@ function getVisibleFileName(file: FileChangeSummary): string {
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined { function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
if (!changeSet) return undefined; if (!changeSet) return undefined;
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`; 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; 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({ export const TeamChangesSection = memo(function TeamChangesSection({
teamName, teamName,
tasks, tasks,
@ -82,13 +97,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
const visibleSummaries = useMemo(() => { const visibleSummaries = useMemo(() => {
return Object.values(summariesByTaskId) return Object.values(summariesByTaskId)
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) })) .map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
.filter( .filter((entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => {
(entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => const changeSet = entry.summary.changeSet;
return (
Boolean(entry.task) && Boolean(entry.task) &&
(Boolean(entry.summary.error) || (Boolean(entry.summary.error) ||
(entry.summary.changeSet?.files.length ?? 0) > 0 || (changeSet?.files.length ?? 0) > 0 ||
(entry.summary.changeSet?.warnings.length ?? 0) > 0) (changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
) );
})
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task)); .sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
}, [summariesByTaskId, taskMap]); }, [summariesByTaskId, taskMap]);
@ -163,13 +180,19 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => { {renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
const changeSet = summary.changeSet; const changeSet = summary.changeSet;
const files = changeSet?.files ?? []; const files = changeSet?.files ?? [];
const reviewability = changeSet
? classifyTaskChangeReviewability(changeSet).reviewability
: 'unknown';
const contributors = getTaskChangeContributors(task, changeSet); const contributors = getTaskChangeContributors(task, changeSet);
const contributorLabel = const contributorLabel =
contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned'; contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
const extraContributors = Math.max(0, contributors.length - 3); const extraContributors = Math.max(0, contributors.length - 3);
const badgeText = getTaskSummaryBadge(changeSet); 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; return null;
} }
@ -210,15 +233,23 @@ export const TeamChangesSection = memo(function TeamChangesSection({
</div> </div>
) : null} ) : null}
{changeSet?.warnings.length ? ( {diagnosticMessages.length ? (
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5"> <div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
{changeSet.warnings.slice(0, 2).map((warning) => ( {diagnosticMessages.slice(0, 2).map((message) => (
<div <div
key={warning} key={message}
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]" className={`flex items-center gap-2 text-xs ${
reviewability === 'attention_required'
? 'text-[var(--step-warning-text)]'
: 'text-[var(--color-text-muted)]'
}`}
> >
<AlertTriangle size={13} className="shrink-0" /> {reviewability === 'attention_required' ? (
<span className="min-w-0 truncate">{warning}</span> <AlertTriangle size={13} className="shrink-0" />
) : (
<Info size={13} className="shrink-0" />
)}
<span className="min-w-0 truncate">{message}</span>
</div> </div>
))} ))}
</div> </div>

View file

@ -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();
}
});
});

View file

@ -24,6 +24,13 @@ import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import {
canMemberShowAttachmentControl,
getAttachmentInputAcceptForMember,
getMemberAttachmentUnavailableReason,
validateAttachmentFilesForMember,
validateAttachmentPayloadsForMember,
} from '@renderer/utils/attachmentRecipientCapabilities';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
@ -147,19 +154,26 @@ export const SendMessageDialog = ({
normalizeOptionalTeamProviderId(selectedMember?.providerId) ?? normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
inferTeamProviderIdFromModel(selectedMember?.model); inferTeamProviderIdFromModel(selectedMember?.model);
const isOpenCodeRecipient = selectedProviderId === 'opencode'; const isOpenCodeRecipient = selectedProviderId === 'opencode';
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
const memberAttachmentUnavailableReason = showAttachmentControl
? getMemberAttachmentUnavailableReason(selectedMember)
: null;
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
const hasTeammates = members.length > 1; const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && isLeadRecipient; const canDelegate = hasTeammates && isLeadRecipient;
const shouldAutoDelegate = canDelegate; const shouldAutoDelegate = canDelegate;
const supportsAttachments = !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient); const supportsAttachments =
!!isTeamAlive && showAttachmentControl && memberAttachmentUnavailableReason == null;
const canAttach = supportsAttachments && canAddMore; const canAttach = supportsAttachments && canAddMore;
const attachmentRestrictionReason = !supportsAttachments const attachmentRestrictionReason = !supportsAttachments
? !isTeamAlive ? !isTeamAlive
? 'Team must be online to attach files' ? 'Team must be online to attach files'
: !isLeadRecipient && !isOpenCodeRecipient : !showAttachmentControl
? 'Files can be sent to the team lead or OpenCode teammates' ? 'Files can be sent to the team lead or OpenCode teammates'
: isOpenCodeRecipient : (memberAttachmentUnavailableReason ??
? 'Team must be online to attach files for OpenCode teammates' (isOpenCodeRecipient
: 'Team must be online to attach files' ? 'Team must be online to attach files for OpenCode teammates'
: 'Team must be online to attach files'))
: undefined; : undefined;
// Auto-switch to delegate when lead recipient is selected, but don't // 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: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(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 trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim();
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
@ -313,13 +332,34 @@ export const SendMessageDialog = ({
const showFileRestrictionError = useCallback(() => { const showFileRestrictionError = useCallback(() => {
setFileRestrictionError( 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); window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => { fileRestrictionTimerRef.current = window.setTimeout(() => {
setFileRestrictionError(null); setFileRestrictionError(null);
}, 4000); }, 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( const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
@ -330,11 +370,15 @@ export const SendMessageDialog = ({
input.value = ''; input.value = '';
return; return;
} }
if (!validateSelectedAttachmentFiles(input.files)) {
input.value = '';
return;
}
void addFiles(input.files); void addFiles(input.files);
} }
input.value = ''; input.value = '';
}, },
[addFiles, canAttach, showFileRestrictionError] [addFiles, canAttach, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
// Cleanup restriction error timer on unmount // Cleanup restriction error timer on unmount
@ -374,9 +418,13 @@ export const SendMessageDialog = ({
} }
return; return;
} }
const files = e.dataTransfer?.files;
if (files?.length && !validateSelectedAttachmentFiles(files)) {
return;
}
handleDrop(e); handleDrop(e);
}, },
[supportsAttachments, handleDrop, showFileRestrictionError] [supportsAttachments, handleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
const handlePasteWrapper = useCallback( const handlePasteWrapper = useCallback(
@ -389,9 +437,17 @@ export const SendMessageDialog = ({
} }
return; 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); handlePaste(e);
}, },
[supportsAttachments, handlePaste, showFileRestrictionError] [supportsAttachments, handlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
return ( return (
@ -430,12 +486,12 @@ export const SendMessageDialog = ({
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor="smd-message">Message</Label> <Label htmlFor="smd-message">Message</Label>
{isLeadRecipient ? ( {showAttachmentControl ? (
<> <>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="*/*" accept={attachmentInputAccept}
multiple multiple
className="hidden" className="hidden"
onChange={handleFileInputChange} onChange={handleFileInputChange}
@ -468,10 +524,14 @@ export const SendMessageDialog = ({
<AttachmentPreviewList <AttachmentPreviewList
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError ?? fileRestrictionError} error={attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason}
onDismissError={clearAttachmentError} onDismissError={clearAttachmentError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient." disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
}
/> />
<div className={quote ? 'flex flex-col' : 'contents'}> <div className={quote ? 'flex flex-col' : 'contents'}>

View file

@ -54,6 +54,7 @@ import {
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { import {
deriveTaskDisplayId, deriveTaskDisplayId,
formatTaskDisplayLabel, formatTaskDisplayLabel,
@ -82,6 +83,7 @@ import {
HelpCircle, HelpCircle,
History, History,
ImageIcon, ImageIcon,
Info,
Link2, Link2,
Loader2, Loader2,
MessageSquare, MessageSquare,
@ -107,6 +109,7 @@ import type {
KanbanTaskState, KanbanTaskState,
ResolvedTeamMember, ResolvedTeamMember,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskChangeReviewability,
TaskChangeSetV2, TaskChangeSetV2,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
@ -168,6 +171,8 @@ export const TaskDetailDialog = ({
const [changesSectionOpen, setChangesSectionOpen] = useState(false); const [changesSectionOpen, setChangesSectionOpen] = useState(false);
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null); const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]); const [taskChangesWarnings, setTaskChangesWarnings] = useState<string[]>([]);
const [taskChangesReviewability, setTaskChangesReviewability] =
useState<TaskChangeReviewability | null>(null);
const [taskChangesLoading, setTaskChangesLoading] = useState(false); const [taskChangesLoading, setTaskChangesLoading] = useState(false);
const [taskChangesError, setTaskChangesError] = useState<string | null>(null); const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null); const loadedTaskChangeSummaryKeyRef = useRef<string | null>(null);
@ -238,6 +243,7 @@ export const TaskDetailDialog = ({
setChangesSectionOpen(false); setChangesSectionOpen(false);
setTaskChangesFiles(null); setTaskChangesFiles(null);
setTaskChangesWarnings([]); setTaskChangesWarnings([]);
setTaskChangesReviewability(null);
setTaskChangesLoading(false); setTaskChangesLoading(false);
setTaskChangesError(null); setTaskChangesError(null);
setLogsRefreshing(false); setLogsRefreshing(false);
@ -395,7 +401,15 @@ export const TaskDetailDialog = ({
const syncTaskChangeSummaryResult = useCallback( const syncTaskChangeSummaryResult = useCallback(
(data: TaskChangeSetV2 | null) => { (data: TaskChangeSetV2 | null) => {
setTaskChangesFiles(data?.files ?? 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; const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && taskChangeRequestOptions) { if (currentTask && taskChangeRequestOptions) {
recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence); recordTaskChangePresence(teamName, currentTask.id, taskChangeRequestOptions, nextPresence);
@ -446,6 +460,7 @@ export const TaskDetailDialog = ({
if (!preserveFilesOnError) { if (!preserveFilesOnError) {
setTaskChangesFiles(null); setTaskChangesFiles(null);
setTaskChangesWarnings([]); setTaskChangesWarnings([]);
setTaskChangesReviewability(null);
} }
setTaskChangesError( setTaskChangesError(
error instanceof Error ? error.message : 'Failed to load task changes summary' error instanceof Error ? error.message : 'Failed to load task changes summary'
@ -592,7 +607,11 @@ export const TaskDetailDialog = ({
? taskChangesFiles && taskChangesFiles.length > 0 ? taskChangesFiles && taskChangesFiles.length > 0
? taskChangesFiles.length ? taskChangesFiles.length
: taskChangesFiles && taskChangesWarnings.length > 0 : taskChangesFiles && taskChangesWarnings.length > 0
? 'attention' ? taskChangesReviewability === 'attention_required'
? 'attention'
: taskChangesReviewability === 'diagnostic_only'
? 'no safe diff'
: undefined
: undefined : undefined
: undefined; : undefined;
@ -1245,19 +1264,33 @@ export const TaskDetailDialog = ({
) : taskChangesFiles ? ( ) : taskChangesFiles ? (
<div className="space-y-2"> <div className="space-y-2">
{taskChangesWarnings.length > 0 ? ( {taskChangesWarnings.length > 0 ? (
<div className="space-y-1 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5"> <div
className={`space-y-1 rounded-md border px-2 py-1.5 ${
taskChangesReviewability === 'attention_required'
? 'border-amber-500/20 bg-amber-500/10'
: 'border-[var(--color-border)] bg-[var(--color-bg-secondary)]'
}`}
>
{taskChangesWarnings.slice(0, 2).map((warning) => ( {taskChangesWarnings.slice(0, 2).map((warning) => (
<div <div
key={warning} key={warning}
className="flex items-center gap-2 text-xs text-[var(--step-warning-text)]" className={`flex items-center gap-2 text-xs ${
taskChangesReviewability === 'attention_required'
? 'text-[var(--step-warning-text)]'
: 'text-[var(--color-text-muted)]'
}`}
> >
<AlertTriangle size={13} className="shrink-0" /> {taskChangesReviewability === 'attention_required' ? (
<AlertTriangle size={13} className="shrink-0" />
) : (
<Info size={13} className="shrink-0" />
)}
<span className="min-w-0 truncate">{warning}</span> <span className="min-w-0 truncate">{warning}</span>
</div> </div>
))} ))}
{taskChangesWarnings.length > 2 ? ( {taskChangesWarnings.length > 2 ? (
<p className="text-[10px] text-[var(--color-text-muted)]"> <p className="text-[10px] text-[var(--color-text-muted)]">
{taskChangesWarnings.length - 2} more warnings {taskChangesWarnings.length - 2} more diagnostics
</p> </p>
) : null} ) : null}
</div> </div>
@ -1337,7 +1370,11 @@ export const TaskDetailDialog = ({
) : changesSectionOpen ? ( ) : changesSectionOpen ? (
<p className="text-xs text-[var(--color-text-muted)]"> <p className="text-xs text-[var(--color-text-muted)]">
{taskChangesWarnings.length > 0 {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'} : 'No file changes recorded'}
</p> </p>
) : null} ) : null}

View file

@ -17,6 +17,13 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import {
canMemberShowAttachmentControl,
getAttachmentInputAcceptForMember,
getMemberAttachmentUnavailableReason,
validateAttachmentFilesForMember,
validateAttachmentPayloadsForMember,
} from '@renderer/utils/attachmentRecipientCapabilities';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { isOpenCodeRuntimeDeliveryHardUxFailureFromDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
@ -288,6 +295,11 @@ export const MessageComposer = ({
normalizeOptionalTeamProviderId(selectedMember?.providerId) ?? normalizeOptionalTeamProviderId(selectedMember?.providerId) ??
inferTeamProviderIdFromModel(selectedMember?.model); inferTeamProviderIdFromModel(selectedMember?.model);
const isOpenCodeRecipient = selectedProviderId === 'opencode'; const isOpenCodeRecipient = selectedProviderId === 'opencode';
const showAttachmentControl = canMemberShowAttachmentControl(selectedMember);
const memberAttachmentUnavailableReason = showAttachmentControl
? getMemberAttachmentUnavailableReason(selectedMember)
: null;
const attachmentInputAccept = getAttachmentInputAcceptForMember(selectedMember);
const hasTeammates = members.length > 1; const hasTeammates = members.length > 1;
const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient); const canDelegate = hasTeammates && (isCrossTeam || isLeadRecipient);
const shouldAutoDelegate = isLeadRecipient && canDelegate; const shouldAutoDelegate = isLeadRecipient && canDelegate;
@ -343,24 +355,34 @@ export const MessageComposer = ({
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// ); // );
const supportsAttachments = const supportsAttachments =
!isCrossTeam && !!isTeamAlive && (isLeadRecipient || isOpenCodeRecipient); !isCrossTeam &&
!!isTeamAlive &&
showAttachmentControl &&
memberAttachmentUnavailableReason == null;
const canAttach = supportsAttachments && draft.canAddMore && !sending; const canAttach = supportsAttachments && draft.canAddMore && !sending;
const attachmentRestrictionReason = !supportsAttachments const attachmentRestrictionReason = !supportsAttachments
? isCrossTeam ? isCrossTeam
? 'File attachments are not supported for cross-team messages' ? 'File attachments are not supported for cross-team messages'
: !isTeamAlive : !isTeamAlive
? 'Team must be online to attach files' ? 'Team must be online to attach files'
: !isLeadRecipient && !isOpenCodeRecipient : !showAttachmentControl
? 'Files can be sent to the team lead or OpenCode teammates' ? 'Files can be sent to the team lead or OpenCode teammates'
: isOpenCodeRecipient : (memberAttachmentUnavailableReason ??
? 'Team must be online to attach files for OpenCode teammates' (isOpenCodeRecipient
: 'Team must be online to attach files' ? 'Team must be online to attach files for OpenCode teammates'
: 'Team must be online to attach files'))
: sending : sending
? 'Wait for current message to finish sending before adding files' ? 'Wait for current message to finish sending before adding files'
: !draft.canAddMore : !draft.canAddMore
? 'Maximum attachments reached' ? 'Maximum attachments reached'
: undefined; : 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 const slashCommandRestrictionReason = standaloneSlashCommand
? draft.attachments.length > 0 ? draft.attachments.length > 0
? 'Slash commands require a live team lead and cannot be sent with attachments' ? 'Slash commands require a live team lead and cannot be sent with attachments'
@ -500,13 +522,34 @@ export const MessageComposer = ({
const showFileRestrictionError = useCallback(() => { const showFileRestrictionError = useCallback(() => {
setFileRestrictionError( 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); window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => { fileRestrictionTimerRef.current = window.setTimeout(() => {
setFileRestrictionError(null); setFileRestrictionError(null);
}, 4000); }, 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 { addFiles: draftAddFiles } = draft;
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
@ -518,11 +561,15 @@ export const MessageComposer = ({
input.value = ''; input.value = '';
return; return;
} }
if (!validateSelectedAttachmentFiles(input.files)) {
input.value = '';
return;
}
void draftAddFiles(input.files); void draftAddFiles(input.files);
} }
input.value = ''; input.value = '';
}, },
[canAttach, draftAddFiles, showFileRestrictionError] [canAttach, draftAddFiles, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
// Cleanup restriction error timer on unmount // Cleanup restriction error timer on unmount
@ -563,9 +610,13 @@ export const MessageComposer = ({
} }
return; return;
} }
const files = e.dataTransfer?.files;
if (files?.length && !validateSelectedAttachmentFiles(files)) {
return;
}
draftHandleDrop(e); draftHandleDrop(e);
}, },
[canAttach, draftHandleDrop, showFileRestrictionError] [canAttach, draftHandleDrop, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
const { handlePaste: draftHandlePaste } = draft; const { handlePaste: draftHandlePaste } = draft;
@ -579,9 +630,17 @@ export const MessageComposer = ({
} }
return; 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); draftHandlePaste(e);
}, },
[canAttach, draftHandlePaste, showFileRestrictionError] [canAttach, draftHandlePaste, showFileRestrictionError, validateSelectedAttachmentFiles]
); );
const remaining = MAX_TEXT_LENGTH - trimmed.length; const remaining = MAX_TEXT_LENGTH - trimmed.length;
@ -625,12 +684,12 @@ export const MessageComposer = ({
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isLeadRecipient ? ( {showAttachmentControl ? (
<> <>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="*/*" accept={attachmentInputAccept}
multiple multiple
className="hidden" className="hidden"
onChange={handleFileInputChange} onChange={handleFileInputChange}
@ -948,10 +1007,16 @@ export const MessageComposer = ({
<AttachmentPreviewList <AttachmentPreviewList
attachments={draft.attachments} attachments={draft.attachments}
onRemove={draft.removeAttachment} onRemove={draft.removeAttachment}
error={draft.attachmentError ?? fileRestrictionError} error={
draft.attachmentError ?? fileRestrictionError ?? attachmentPayloadRestrictionReason
}
onDismissError={draft.clearAttachmentError} onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient." disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
}
/> />
) : null} ) : null}
</div> </div>

View file

@ -21,7 +21,8 @@ import {
type TaskChangeRequestOptions, type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { normalizePathForComparison } from '@shared/utils/platformPath'; 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 { ChangesLoadingAnimation } from './ChangesLoadingAnimation';
import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils'; import { acceptAllChunks, computeChunkIndexAtPos, rejectAllChunks } from './CodeMirrorDiffUtils';
@ -75,28 +76,51 @@ const TaskChangesEmptyState = ({
}: { }: {
changeSet: TaskChangeSetV2 | null; changeSet: TaskChangeSetV2 | null;
}): React.ReactElement => { }): React.ReactElement => {
const warnings = changeSet?.warnings ?? []; const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null;
const hasWarnings = warnings.length > 0; const diagnosticMessages =
const Icon = hasWarnings ? AlertTriangle : FileSearch; 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 ( return (
<div className="flex w-full items-center justify-center px-6"> <div className="flex w-full items-center justify-center px-6">
<div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center"> <div className="max-w-xl rounded-lg border border-border bg-surface-sidebar px-5 py-4 text-center">
<Icon <Icon
className={cn('mx-auto mb-2 size-5', hasWarnings ? 'text-amber-300' : 'text-text-muted')} className={cn('mx-auto mb-2 size-5', isAttention ? 'text-amber-300' : 'text-text-muted')}
/> />
<div className="text-sm font-medium text-text"> <div className="text-sm font-medium text-text">{title}</div>
{hasWarnings ? 'No reviewable file changes' : 'No file changes recorded'} <p className="mt-1 text-xs leading-5 text-text-muted">{description}</p>
</div> {uniqueMessages.length > 0 && (
<p className="mt-1 text-xs leading-5 text-text-muted"> <div
{hasWarnings className={cn(
? 'The task ledger did not expose any safe file diff for this task. The diagnostics below explain why.' 'mt-3 space-y-1 rounded border px-3 py-2 text-left text-xs',
: 'The task ledger has no file events for this task.'} isAttention
</p> ? 'border-amber-500/20 bg-amber-500/10 text-amber-200'
{warnings.length > 0 && ( : 'border-border bg-surface-raised text-text-muted'
<div className="mt-3 space-y-1 rounded border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-left text-xs text-amber-200"> )}
{warnings.map((warning, index) => ( >
<div key={`${warning}:${index}`}>{warning}</div> {uniqueMessages.map((message, index) => (
<div key={`${message}:${index}`}>{message}</div>
))} ))}
</div> </div>
)} )}
@ -1179,7 +1203,7 @@ export const ChangeReviewDialog = ({
mode === 'task' && mode === 'task' &&
!!taskChangeSet && !!taskChangeSet &&
(taskChangeSet.provenance?.sourceKind !== 'ledger' || (taskChangeSet.provenance?.sourceKind !== 'ledger' ||
taskChangeSet.warnings.length > 0 || classifyTaskChangeReviewability(taskChangeSet).reviewability === 'attention_required' ||
taskChangeSet.scope.confidence.tier > 1); taskChangeSet.scope.confidence.tier > 1);
// Active file for timeline (derived from scroll-spy) // Active file for timeline (derived from scroll-spy)

View file

@ -0,0 +1,19 @@
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000;
export function withTeamChangesLoadTimeout<T>(
promise: Promise<T>,
timeoutMs = TEAM_CHANGES_LOAD_TIMEOUT_MS
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
reject(new Error('Team changes request timed out. Refresh to try again.'));
}, timeoutMs);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
});
}

View file

@ -4,6 +4,7 @@ import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import { import {
buildTeamChangeRequestPlan, buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint, buildTeamChangesTasksFingerprint,
@ -136,7 +137,9 @@ export function useTeamChangesSummaries({
activeRequestSeqRef.current = requestSeq; activeRequestSeqRef.current = requestSeq;
try { 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) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return; return;
} }

View file

@ -27,9 +27,12 @@ import { structuredPatch } from 'diff';
const taskChangesCheckInFlight = new Set<string>(); const taskChangesCheckInFlight = new Set<string>();
/** Tracks background presence revalidation for optimistic terminal summary hits */ /** Tracks background presence revalidation for optimistic terminal summary hits */
const taskChangesPresenceRevalidationInFlight = new Set<string>(); const taskChangesPresenceRevalidationInFlight = new Set<string>();
/** Rate-limits forced refreshes for cached needs_attention hits */
const taskChangesNeedsAttentionRevalidationTs = new Map<string, number>();
/** Negative results cached with timestamp — recheck after 30s */ /** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>(); const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000; const NEGATIVE_CACHE_TTL = 30_000;
const NEEDS_ATTENTION_REVALIDATION_TTL = 30_000;
const TASK_CHANGE_WARM_CONCURRENCY = 4; const TASK_CHANGE_WARM_CONCURRENCY = 4;
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
let latestAgentChangesRequestToken = 0; let latestAgentChangesRequestToken = 0;
@ -1539,13 +1542,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
const summaryCacheable = isTaskSummaryCacheableForOptions(options); const summaryCacheable = isTaskSummaryCacheableForOptions(options);
const cachedPresence = get().taskChangePresenceByKey[cacheKey]; const cachedPresence = get().taskChangePresenceByKey[cacheKey];
if ( if (summaryCacheable && cachedPresence === 'has_changes') {
summaryCacheable &&
(cachedPresence === 'has_changes' || cachedPresence === 'needs_attention')
) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence); get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
return; return;
} }
if (summaryCacheable && cachedPresence === 'needs_attention') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
const lastRevalidationMs = taskChangesNeedsAttentionRevalidationTs.get(cacheKey) ?? 0;
if (Date.now() - lastRevalidationMs >= NEEDS_ATTENTION_REVALIDATION_TTL) {
taskChangesNeedsAttentionRevalidationTs.set(cacheKey, Date.now());
void revalidateTaskChangePresence(teamName, taskId, options);
}
return;
}
if (taskChangesCheckInFlight.has(cacheKey)) return; if (taskChangesCheckInFlight.has(cacheKey)) return;
const negativeTs = taskChangesNegativeCache.get(cacheKey); const negativeTs = taskChangesNegativeCache.get(cacheKey);
const hasUnknownPresence = selectedTask?.changePresence === 'unknown'; const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
@ -1627,14 +1636,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
summaryOnly: true, summaryOnly: true,
}); });
const nextPresence = resolveTaskChangePresenceFromResult(data); const nextPresence = resolveTaskChangePresenceFromResult(data);
if (nextPresence) { set((s) => ({
set((s) => ({ taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
taskChangePresenceByKey: { s.taskChangePresenceByKey,
...s.taskChangePresenceByKey, cacheKey,
[cacheKey]: nextPresence, nextPresence
}, ),
})); }));
}
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
taskChangesNegativeCache.delete(cacheKey); taskChangesNegativeCache.delete(cacheKey);
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) { if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
@ -1673,6 +1681,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
changed = true; changed = true;
} }
taskChangesNegativeCache.delete(key); taskChangesNegativeCache.delete(key);
taskChangesNeedsAttentionRevalidationTs.delete(key);
} }
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {}; return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
}); });

View file

@ -0,0 +1,178 @@
import {
resolveAgentAttachmentCapability,
type AgentAttachmentCapability,
} from '@features/agent-attachments/renderer';
import { categorizeFile, getEffectiveMimeType, isImageMime } from '@shared/constants/attachments';
import { isLeadMember } from '@shared/utils/leadDetection';
import {
inferTeamProviderIdFromModel,
normalizeOptionalTeamProviderId,
} from '@shared/utils/teamProvider';
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
export interface MemberAttachmentCapabilityResult {
capability: AgentAttachmentCapability;
providerId: string;
model: string;
}
function getMemberProviderId(member: ResolvedTeamMember): string {
return (
normalizeOptionalTeamProviderId(member.providerId) ??
inferTeamProviderIdFromModel(member.model) ??
'unknown'
);
}
function isSupportedFileMime(mimeType: string, supported: readonly string[]): boolean {
return supported.some((candidate) =>
candidate.endsWith('/*') ? mimeType.startsWith(candidate.slice(0, -1)) : candidate === mimeType
);
}
function canReceiveAnyAttachment(capability: AgentAttachmentCapability): boolean {
return capability.supportsImages || capability.supportsFiles;
}
export function resolveMemberAttachmentCapability(
member: ResolvedTeamMember
): MemberAttachmentCapabilityResult {
const providerId = getMemberProviderId(member);
const model = member.model ?? '';
return {
providerId,
model,
capability: resolveAgentAttachmentCapability({ providerId, model }),
};
}
export function getMemberAttachmentUnavailableReason(
member: ResolvedTeamMember | null | undefined
): string | null {
if (!member) {
return 'Select a recipient before attaching files.';
}
const { capability } = resolveMemberAttachmentCapability(member);
if (canReceiveAnyAttachment(capability)) {
return null;
}
return capability.displayText;
}
export function getAttachmentInputAcceptForMember(
member: ResolvedTeamMember | null | undefined
): string {
if (!member) {
return '*/*';
}
const { capability } = resolveMemberAttachmentCapability(member);
if (capability.supportsImages && !capability.supportsFiles) {
return 'image/png,image/jpeg,image/webp';
}
return '*/*';
}
export function validateAttachmentFilesForMember(input: {
member: ResolvedTeamMember | null | undefined;
files: FileList | File[];
}): string | null {
const member = input.member;
if (!member) {
return 'Select a recipient before attaching files.';
}
const files = Array.from(input.files);
if (files.length === 0) {
return null;
}
const { capability } = resolveMemberAttachmentCapability(member);
if (!canReceiveAnyAttachment(capability)) {
return capability.displayText;
}
for (const file of files) {
const category = categorizeFile(file);
if (category === 'unsupported') {
continue;
}
if (category === 'image') {
if (!capability.supportsImages) {
return capability.displayText;
}
continue;
}
if (!capability.supportsFiles) {
return capability.filesDisplayText;
}
const mimeType = getEffectiveMimeType(file);
if (!isSupportedFileMime(mimeType, capability.supportedFileMimeTypes)) {
return 'This file type is not supported by the selected model.';
}
}
return null;
}
export function validateAttachmentPayloadsForMember(input: {
member: ResolvedTeamMember | null | undefined;
attachments: readonly AttachmentPayload[];
}): string | null {
const member = input.member;
if (!member || input.attachments.length === 0) {
return null;
}
const { capability } = resolveMemberAttachmentCapability(member);
if (!canReceiveAnyAttachment(capability)) {
return capability.displayText;
}
let imageCount = 0;
let fileCount = 0;
let totalBytes = 0;
for (const attachment of input.attachments) {
totalBytes += attachment.size;
if (isImageMime(attachment.mimeType)) {
imageCount += 1;
if (!capability.supportsImages) {
return capability.displayText;
}
if (attachment.size > capability.maxBytesPerImage) {
return 'Image is too large for the selected model.';
}
continue;
}
fileCount += 1;
if (!capability.supportsFiles) {
return capability.filesDisplayText;
}
if (!isSupportedFileMime(attachment.mimeType, capability.supportedFileMimeTypes)) {
return 'This file type is not supported by the selected model.';
}
if (attachment.size > capability.maxBytesPerFile) {
return 'File is too large for the selected model.';
}
}
if (imageCount > capability.maxImages) {
return `Maximum ${capability.maxImages} image attachments for this model.`;
}
if (fileCount > capability.maxFiles) {
return `Maximum ${capability.maxFiles} file attachments for this model.`;
}
if (totalBytes > capability.maxBytesTotal) {
return 'Attachments exceed the selected model size limit.';
}
return null;
}
export function canMemberShowAttachmentControl(
member: ResolvedTeamMember | null | undefined
): boolean {
if (!member) {
return false;
}
const providerId = getMemberProviderId(member);
return isLeadMember(member) || providerId === 'opencode';
}

View file

@ -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.'; 'OpenCode reply could not be verified. Message was saved to inbox, but no visible reply or task progress proof was found.';
const FAILED_WARNING = const FAILED_WARNING =
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.'; '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 { function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
const normalized = reason?.trim(); const normalized = reason?.trim();
@ -69,6 +80,33 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (normalizedLower === 'non_visible_tool_without_task_progress') { if (normalizedLower === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; 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 ''; return '';
} }
@ -94,12 +132,16 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
} }
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim(); const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
const failureReason = const candidateFailureReason =
isFailed || isWarning userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0];
? formatOpenCodeRuntimeDeliveryFailureReason( const mappedFailureReason =
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0] 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; const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
return { return {
@ -108,11 +150,15 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
? `${PROOF_WARNING} Reason: ${failureReason}` ? `${PROOF_WARNING} Reason: ${failureReason}`
: isWarning : isWarning
? PROOF_WARNING ? PROOF_WARNING
: isFailed && failureReason : isAttachmentFailure && failureReason
? `${FAILED_WARNING} Reason: ${failureReason}` ? `${ATTACHMENT_FAILED_WARNING} Reason: ${failureReason}`
: isFailed : isAttachmentFailure
? FAILED_WARNING ? ATTACHMENT_FAILED_WARNING
: PENDING_WARNING, : isFailed && failureReason
? `${FAILED_WARNING} Reason: ${failureReason}`
: isFailed
? FAILED_WARNING
: PENDING_WARNING,
debugDetails: { debugDetails: {
messageId: result.messageId, messageId: result.messageId,
statusMessageId, statusMessageId,

View file

@ -132,6 +132,65 @@ export interface TaskChangeSet {
computedAt: string; 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 */ /** Краткая статистика для badge */
export interface ChangeStats { export interface ChangeStats {
linesAdded: number; linesAdded: number;
@ -287,6 +346,7 @@ export interface TaskBoundariesResult {
export interface TaskChangeSetV2 extends TaskChangeSet { export interface TaskChangeSetV2 extends TaskChangeSet {
scope: TaskChangeScope; scope: TaskChangeScope;
warnings: string[]; warnings: string[];
reviewDiagnostics?: TaskChangeReviewDiagnostic[];
diffStatCompleteness?: 'complete' | 'partial'; diffStatCompleteness?: 'complete' | 'partial';
provenance?: TaskChangeProvenance; provenance?: TaskChangeProvenance;
} }

View file

@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import { resolveTaskChangePresenceFromResult } from '../taskChangePresence';
import {
classifyTaskChangeReviewability,
EMPTY_INTERVAL_NO_EDITS_WARNING,
} from '../taskChangeReviewability';
import type { TaskChangeSetV2 } from '../../types';
function changeSet(overrides: Partial<TaskChangeSetV2> = {}): TaskChangeSetV2 {
return {
teamName: 'team-a',
taskId: 'task-a',
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'high',
computedAt: '2026-05-09T12:00:00.000Z',
scope: {
taskId: 'task-a',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '2026-05-09T11:00:00.000Z',
endTimestamp: '2026-05-09T11:10:00.000Z',
toolUseIds: [],
filePaths: [],
confidence: { tier: 1, label: 'high', reason: 'test' },
},
warnings: [],
...overrides,
};
}
describe('taskChangeReviewability', () => {
it('treats changed files with non-blocking multi-scope diagnostics as reviewable', () => {
const result = changeSet({
files: [
{
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalFiles: 1,
totalLinesAdded: 1,
warnings: [
'Task change ledger skipped attribution because multiple task scopes were active.',
],
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('reviewable');
expect(resolveTaskChangePresenceFromResult(result)).toBe('has_changes');
});
it('classifies warning-only multi-scope notices as diagnostic-only', () => {
const result = changeSet({
warnings: [
'Task change ledger skipped attribution because multiple task scopes were active.',
],
});
expect(classifyTaskChangeReviewability(result)).toMatchObject({
reviewability: 'diagnostic_only',
reasonCode: 'diagnostic_only',
userAction: 'inspect_diagnostics',
});
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
});
it('fails closed for unclassified warning-only summaries', () => {
const result = changeSet({ warnings: ['Unexpected ledger warning.'] });
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
});
it('keeps active no-edit intervals unknown instead of needs attention', () => {
const result = changeSet({
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
scope: {
...changeSet().scope,
startTimestamp: '2026-05-09T11:00:00.000Z',
endTimestamp: '',
toolUseIds: [],
},
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
});
it('keeps active no-edit intervals fail-closed when blocking diagnostics are present', () => {
const result = changeSet({
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING, 'Task changes scan timed out.'],
scope: {
...changeSet().scope,
startTimestamp: '2026-05-09T11:00:00.000Z',
endTimestamp: '',
toolUseIds: [],
},
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
});
it('marks partial ledger evidence as attention required', () => {
const result = changeSet({
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'fingerprint',
integrity: 'partial',
},
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('attention_required');
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
});
it('deduplicates recovered ledger diagnostics from typed diagnostics and provenance', () => {
const result = changeSet({
reviewDiagnostics: [
{
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
source: 'ledger',
},
],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'fingerprint',
integrity: 'recovered',
},
});
const status = classifyTaskChangeReviewability(result);
expect(status.reviewability).toBe('attention_required');
expect(status.diagnostics).toHaveLength(1);
expect(status.diagnostics[0]?.code).toBe('ledger_integrity_recovered');
});
it('does not downgrade typed blocking diagnostics when legacy warnings duplicate them', () => {
const result = changeSet({
reviewDiagnostics: [
{
code: 'multi_scope_no_safe_diff',
severity: 'warning',
reviewBlocking: true,
message:
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
source: 'ledger',
},
],
warnings: [
'Task change ledger skipped attribution because multiple task scopes were active.',
],
});
const status = classifyTaskChangeReviewability(result);
expect(status.reviewability).toBe('attention_required');
expect(status.diagnostics).toHaveLength(1);
expect(status.diagnostics[0]?.reviewBlocking).toBe(true);
});
it('upgrades duplicate diagnostics when legacy warnings are more strict', () => {
const result = changeSet({
reviewDiagnostics: [
{
code: 'legacy_warning',
severity: 'info',
reviewBlocking: false,
message: 'Unexpected ledger warning.',
source: 'summary',
},
],
warnings: ['Unexpected ledger warning.'],
});
const status = classifyTaskChangeReviewability(result);
expect(status.reviewability).toBe('attention_required');
expect(status.diagnostics).toHaveLength(1);
expect(status.diagnostics[0]).toMatchObject({
code: 'legacy_warning',
severity: 'warning',
reviewBlocking: true,
source: 'legacy',
});
});
it('fails closed when reported files are missing safe review details', () => {
const result = changeSet({
totalFiles: 2,
files: [
{
filePath: '/repo/src/file.ts',
relativePath: 'src/file.ts',
snippets: [],
linesAdded: 1,
linesRemoved: 0,
isNewFile: true,
},
],
totalLinesAdded: 1,
});
const status = classifyTaskChangeReviewability(result);
expect(status).toMatchObject({
reviewability: 'attention_required',
reasonCode: 'blocking_diagnostics',
userAction: 'review_diff',
});
expect(status.diagnostics).toContainEqual(
expect.objectContaining({
code: 'unsafe_or_untrusted_evidence',
reviewBlocking: true,
})
);
expect(resolveTaskChangePresenceFromResult(result)).toBe('needs_attention');
});
it('tolerates malformed cached scope and diagnostic shapes', () => {
const result = changeSet({
totalFiles: 'not-a-number' as unknown as number,
reviewDiagnostics: {} as unknown as TaskChangeSetV2['reviewDiagnostics'],
warnings: [EMPTY_INTERVAL_NO_EDITS_WARNING],
scope: {
taskId: 'task-a',
memberName: 'alice',
startTimestamp: '2026-05-09T11:00:00.000Z',
endTimestamp: '',
confidence: { tier: 2, label: 'medium', reason: 'legacy cache fixture' },
} as unknown as TaskChangeSetV2['scope'],
});
expect(classifyTaskChangeReviewability(result).reviewability).toBe('unknown');
expect(resolveTaskChangePresenceFromResult(result)).toBeNull();
});
it('confirms empty high-confidence summaries as no changes', () => {
const result = changeSet();
expect(classifyTaskChangeReviewability(result).reviewability).toBe('none');
expect(resolveTaskChangePresenceFromResult(result)).toBe('no_changes');
});
});

View file

@ -1,35 +1,21 @@
import { classifyTaskChangeReviewability } from './taskChangeReviewability';
import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types'; import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types';
const EMPTY_INTERVAL_NO_EDITS_WARNING = 'No file edits found within persisted workIntervals.';
function isBenignActiveIntervalWithoutFileEdits(
data: Pick<TaskChangeSetV2, 'files' | 'warnings' | 'scope'>
): boolean {
if (data.files.length > 0) {
return false;
}
if (data.warnings.length !== 1 || data.warnings[0] !== EMPTY_INTERVAL_NO_EDITS_WARNING) {
return false;
}
return Boolean(data.scope.startTimestamp) && !data.scope.endTimestamp && data.scope.toolUseIds.length === 0;
}
export function resolveTaskChangePresenceFromResult( export function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence' | 'warnings' | 'scope'> data: Pick<TaskChangeSetV2, 'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'> &
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>
): Exclude<TaskChangePresenceState, 'unknown'> | null { ): Exclude<TaskChangePresenceState, 'unknown'> | null {
if (data.files.length > 0) { const status = classifyTaskChangeReviewability(data);
return 'has_changes'; 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;
} }

View file

@ -0,0 +1,391 @@
import { TASK_CHANGE_DIAGNOSTIC_CODES } from '../types';
import type {
TaskChangeDiagnosticCode,
TaskChangeDiagnosticSeverity,
TaskChangeReviewabilityStatus,
TaskChangeReviewDiagnostic,
TaskChangeSetV2,
} from '../types';
export const EMPTY_INTERVAL_NO_EDITS_WARNING =
'No file edits found within persisted workIntervals.';
const MULTI_SCOPE_MESSAGES = [
'Task change ledger skipped attribution because multiple task scopes were active.',
'Ledger skipped attribution because multiple task scopes were active.',
] as const;
const TASK_CHANGE_DIAGNOSTIC_CODE_SET = new Set<string>(TASK_CHANGE_DIAGNOSTIC_CODES);
type ReviewabilityInput = Pick<
TaskChangeSetV2,
'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'
> &
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>;
interface DiagnosticTemplate {
code: TaskChangeDiagnosticCode;
severity: TaskChangeDiagnosticSeverity;
reviewBlocking: boolean;
message: string;
}
function templateForLegacyWarning(warning: string): DiagnosticTemplate {
const trimmed = warning.trim();
const normalized = trimmed.toLowerCase();
if (MULTI_SCOPE_MESSAGES.some((message) => message.toLowerCase() === normalized)) {
return {
code: 'multi_scope_no_safe_diff',
severity: 'info',
reviewBlocking: false,
message:
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
};
}
if (normalized === EMPTY_INTERVAL_NO_EDITS_WARNING.toLowerCase()) {
return {
code: 'active_task_no_edits_yet',
severity: 'info',
reviewBlocking: false,
message: 'No file edits have been observed in the active task interval yet.',
};
}
if (normalized.includes('timed out')) {
return {
code: 'summary_timeout',
severity: 'warning',
reviewBlocking: true,
message: 'The changes scan timed out before it could finish.',
};
}
if (normalized.includes('fell back to journal reconstruction')) {
return {
code: 'summary_reconstructed',
severity: 'info',
reviewBlocking: false,
message: 'The change summary was reconstructed from the task-change journal.',
};
}
if (normalized.includes('journal was unavailable')) {
return {
code: 'journal_unavailable',
severity: 'warning',
reviewBlocking: true,
message: 'Detailed ledger entries were unavailable for this task.',
};
}
if (normalized.includes('recovered from malformed journal lines')) {
return {
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
};
}
if (
normalized.includes('freshness did not match') ||
normalized.includes('partial') ||
normalized.includes('integrity')
) {
return {
code: 'ledger_integrity_partial',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger may be incomplete or stale.',
};
}
if (normalized.startsWith('tool ') && normalized.includes(' failed after changing files')) {
return {
code: 'tool_failed_after_edit',
severity: 'warning',
reviewBlocking: true,
message: 'A tool failed after changing files.',
};
}
if (
normalized.startsWith('background tool ') &&
normalized.includes(' was killed after changing files')
) {
return {
code: 'tool_killed_after_edit',
severity: 'warning',
reviewBlocking: true,
message: 'A background tool was killed after changing files.',
};
}
return {
code: 'legacy_warning',
severity: 'warning',
reviewBlocking: true,
message: trimmed || 'The change summary reported an unclassified warning.',
};
}
export function createTaskChangeDiagnosticFromWarning(
warning: string,
source: TaskChangeReviewDiagnostic['source'] = 'legacy'
): TaskChangeReviewDiagnostic {
const template = templateForLegacyWarning(warning);
return { ...template, source };
}
function diagnosticKey(diagnostic: TaskChangeReviewDiagnostic): string {
return `${diagnostic.code}:${diagnostic.message}`;
}
function diagnosticSeverityRank(severity: TaskChangeDiagnosticSeverity): number {
switch (severity) {
case 'error':
return 3;
case 'warning':
return 2;
case 'info':
return 1;
}
}
export function mergeTaskChangeReviewDiagnostics(
existing: TaskChangeReviewDiagnostic,
incoming: TaskChangeReviewDiagnostic
): TaskChangeReviewDiagnostic {
if (
incoming.reviewBlocking &&
(!existing.reviewBlocking ||
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity))
) {
return incoming;
}
if (
existing.reviewBlocking === incoming.reviewBlocking &&
diagnosticSeverityRank(incoming.severity) > diagnosticSeverityRank(existing.severity)
) {
return incoming;
}
return existing;
}
function addDiagnostic(
diagnostics: Map<string, TaskChangeReviewDiagnostic>,
diagnostic: TaskChangeReviewDiagnostic
): void {
const key = diagnosticKey(diagnostic);
const existing = diagnostics.get(key);
if (existing) {
diagnostics.set(key, mergeTaskChangeReviewDiagnostics(existing, diagnostic));
} else {
diagnostics.set(key, diagnostic);
}
}
function getInputFiles(input: ReviewabilityInput): TaskChangeSetV2['files'] {
return Array.isArray(input.files) ? input.files : [];
}
function getInputWarnings(input: ReviewabilityInput): string[] {
return Array.isArray(input.warnings)
? input.warnings.filter((warning): warning is string => typeof warning === 'string')
: [];
}
function getInputReviewDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
if (!Array.isArray(input.reviewDiagnostics)) {
return [];
}
return input.reviewDiagnostics.filter((diagnostic): diagnostic is TaskChangeReviewDiagnostic => {
if (!diagnostic || typeof diagnostic !== 'object' || Array.isArray(diagnostic)) {
return false;
}
const candidate = diagnostic as Partial<TaskChangeReviewDiagnostic>;
return (
typeof candidate.code === 'string' &&
TASK_CHANGE_DIAGNOSTIC_CODE_SET.has(candidate.code) &&
(candidate.severity === 'info' ||
candidate.severity === 'warning' ||
candidate.severity === 'error') &&
typeof candidate.reviewBlocking === 'boolean' &&
typeof candidate.message === 'string'
);
});
}
function getInputToolUseIds(input: ReviewabilityInput): string[] {
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
return Array.isArray(scope?.toolUseIds) ? scope.toolUseIds : [];
}
function getInputStartTimestamp(input: ReviewabilityInput): string {
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
return typeof scope?.startTimestamp === 'string' ? scope.startTimestamp : '';
}
function getInputEndTimestamp(input: ReviewabilityInput): string {
const scope = input.scope as Partial<TaskChangeSetV2['scope']> | undefined;
return typeof scope?.endTimestamp === 'string' ? scope.endTimestamp : '';
}
function getInputTotalFiles(input: ReviewabilityInput, fileCount: number): number {
const totalFiles = Number(input.totalFiles);
if (!Number.isFinite(totalFiles) || totalFiles < 0) {
return fileCount;
}
return Math.trunc(totalFiles);
}
function collectDiagnostics(input: ReviewabilityInput): TaskChangeReviewDiagnostic[] {
const diagnostics = new Map<string, TaskChangeReviewDiagnostic>();
for (const diagnostic of getInputReviewDiagnostics(input)) {
addDiagnostic(diagnostics, diagnostic);
}
for (const warning of getInputWarnings(input)) {
const diagnostic = createTaskChangeDiagnosticFromWarning(warning);
addDiagnostic(diagnostics, diagnostic);
}
if (input.diffStatCompleteness === 'partial') {
const diagnostic: TaskChangeReviewDiagnostic = {
code: 'diff_stat_partial',
severity: 'warning',
reviewBlocking: true,
message: 'Some file change statistics are incomplete.',
source: 'summary',
};
addDiagnostic(diagnostics, diagnostic);
}
if (input.provenance?.integrity === 'partial') {
const diagnostic: TaskChangeReviewDiagnostic = {
code: 'ledger_integrity_partial',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger is partially available.',
source: 'ledger',
};
addDiagnostic(diagnostics, diagnostic);
} else if (input.provenance?.integrity === 'recovered') {
const diagnostic: TaskChangeReviewDiagnostic = {
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
source: 'ledger',
};
addDiagnostic(diagnostics, diagnostic);
}
const fileCount = getInputFiles(input).length;
const totalFiles = getInputTotalFiles(input, fileCount);
if (totalFiles > fileCount) {
const missingFileCount = totalFiles - fileCount;
const diagnostic: TaskChangeReviewDiagnostic = {
code: 'unsafe_or_untrusted_evidence',
severity: 'warning',
reviewBlocking: true,
message:
missingFileCount === 1
? 'The change summary reported one file without safe review details.'
: `The change summary reported ${missingFileCount} files without safe review details.`,
source: 'summary',
};
addDiagnostic(diagnostics, diagnostic);
}
return [...diagnostics.values()];
}
function isActiveIntervalWithoutFileEdits(
input: ReviewabilityInput,
diagnostics: TaskChangeReviewDiagnostic[]
): boolean {
return (
getInputFiles(input).length === 0 &&
diagnostics.some((diagnostic) => diagnostic.code === 'active_task_no_edits_yet') &&
Boolean(getInputStartTimestamp(input)) &&
!getInputEndTimestamp(input) &&
getInputToolUseIds(input).length === 0
);
}
export function classifyTaskChangeReviewability(
input: ReviewabilityInput
): TaskChangeReviewabilityStatus {
const diagnostics = collectDiagnostics(input);
const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.reviewBlocking);
const hasFiles = getInputFiles(input).length > 0;
if (blockingDiagnostics.length > 0) {
return {
reviewability: 'attention_required',
reasonCode: 'blocking_diagnostics',
userAction: hasFiles ? 'review_diff' : 'inspect_diagnostics',
severity: 'warning',
message: hasFiles ? 'Changes may be incomplete.' : 'Changes need attention.',
diagnostics,
};
}
if (isActiveIntervalWithoutFileEdits(input, diagnostics)) {
return {
reviewability: 'unknown',
reasonCode: 'pending_no_edits_yet',
userAction: 'wait_or_refresh',
severity: 'none',
message: 'No file edits have been observed yet.',
diagnostics,
};
}
if (hasFiles) {
return {
reviewability: 'reviewable',
reasonCode:
diagnostics.length > 0 ? 'files_changed_with_non_blocking_diagnostics' : 'files_changed',
userAction: 'review_diff',
severity: 'success',
message: 'Reviewable file changes are available.',
diagnostics,
};
}
if (diagnostics.length > 0) {
return {
reviewability: 'diagnostic_only',
reasonCode: 'diagnostic_only',
userAction: 'inspect_diagnostics',
severity: 'info',
message: 'No safe diff is available for this task.',
diagnostics,
};
}
if (input.confidence === 'high' || input.confidence === 'medium') {
return {
reviewability: 'none',
reasonCode: 'confirmed_no_changes',
userAction: 'nothing',
severity: 'none',
message: 'No reviewable file changes were found.',
diagnostics,
};
}
return {
reviewability: 'unknown',
reasonCode: 'low_confidence',
userAction: 'wait_or_refresh',
severity: 'none',
message: 'The change summary is not confident enough yet.',
diagnostics,
};
}

View file

@ -4,6 +4,7 @@ module.exports = {
content: [ content: [
'./src/renderer/index.html', './src/renderer/index.html',
'./src/renderer/**/*.{js,ts,jsx,tsx}', './src/renderer/**/*.{js,ts,jsx,tsx}',
'./src/features/**/*.{js,ts,jsx,tsx}',
'./src/shared/**/*.{js,ts,jsx,tsx}', './src/shared/**/*.{js,ts,jsx,tsx}',
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}' './packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
], ],

View file

@ -822,6 +822,9 @@ describe('MemberWorkSync use cases', () => {
const inbox = new InMemoryInboxNudge(); const inbox = new InMemoryInboxNudge();
const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> = const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> =
[]; [];
const busyCalls: Parameters<
NonNullable<MemberWorkSyncUseCaseDeps['busySignal']>['isBusy']
>[0][] = [];
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = { const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
canDeliver: async () => ({ ok: true }), canDeliver: async () => ({ ok: true }),
deliver: async (input) => { deliver: async (input) => {
@ -840,6 +843,12 @@ describe('MemberWorkSync use cases', () => {
outboxStore: outbox, outboxStore: outbox,
inboxNudge: inbox, inboxNudge: inbox,
reviewPickupDelivery, reviewPickupDelivery,
busySignal: {
isBusy: (input) => {
busyCalls.push(input);
return Promise.resolve({ busy: false });
},
},
}); });
await new MemberWorkSyncReconciler(deps).execute( await new MemberWorkSyncReconciler(deps).execute(
@ -853,6 +862,15 @@ describe('MemberWorkSync use cases', () => {
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 }); expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
expect(inbox.inserted).toHaveLength(1); 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).toHaveLength(1);
expect(deliveryCalls[0]).toMatchObject({ expect(deliveryCalls[0]).toMatchObject({
messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request', messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',

View file

@ -368,7 +368,10 @@ function createService(params: {
logPaths: string[]; logPaths: string[];
projectPath?: string; projectPath?: string;
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>; findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> }; taskChangePresenceRepository?: {
upsertEntry: ReturnType<typeof vi.fn>;
deleteEntry?: ReturnType<typeof vi.fn>;
};
teamLogSourceTracker?: { teamLogSourceTracker?: {
ensureTracking: ReturnType< ensureTracking: ReturnType<
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> 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-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir); await writeTaskFile(tmpDir);
const upsertEntry = vi.fn(async () => undefined); const upsertEntry = vi.fn(() => Promise.resolve(undefined));
const ensureTracking = vi.fn(async () => ({ const deleteEntry = vi.fn(() => Promise.resolve(undefined));
projectFingerprint: 'project-fingerprint', const ensureTracking = vi.fn(() =>
logSourceGeneration: 'log-generation', Promise.resolve({
})); projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})
);
const workerClient = { const workerClient = {
isAvailable: vi.fn(() => true), isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () => computeTaskChanges: vi.fn(() =>
makeTaskChangeResult(TASK_ID, { Promise.resolve(
content: '', makeTaskChangeResult(TASK_ID, {
confidence: 'fallback', content: '',
warning: 'Ledger skipped attribution because multiple task scopes were active.', 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({ const { service } = createService({
@ -1128,9 +1177,7 @@ describe('ChangeExtractorService', () => {
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(result.files).toHaveLength(0); expect(result.files).toHaveLength(0);
expect(result.warnings).toEqual([ expect(result.warnings).toEqual(['Unexpected ledger warning.']);
'Ledger skipped attribution because multiple task scopes were active.',
]);
expect(upsertEntry).toHaveBeenCalledWith( expect(upsertEntry).toHaveBeenCalledWith(
TEAM_NAME, TEAM_NAME,
expect.objectContaining({ expect.objectContaining({

View file

@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository'; import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import { resolveTaskChangePresenceFromResult } from '../../../../src/shared/utils/taskChangePresence';
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes'; import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
@ -96,6 +97,64 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
).toContain('"teamName": "team-a"'); ).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 () => { it('treats expired entries as cache misses', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -110,7 +169,7 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository(); 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'); const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{bad-json', 'utf8'); await fs.writeFile(filePath, '{bad-json', 'utf8');

View file

@ -0,0 +1,352 @@
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('electron', () => ({
app: {
getPath: () => os.tmpdir(),
getVersion: () => '1.3.0-e2e',
isPackaged: false,
},
BrowserWindow: vi.fn(),
dialog: {},
ipcMain: { handle: vi.fn(), on: vi.fn(), removeHandler: vi.fn() },
nativeImage: { createFromPath: vi.fn(() => ({})) },
net: {},
Notification: vi.fn(),
safeStorage: {
decryptString: vi.fn(),
encryptString: vi.fn(),
isEncryptionAvailable: vi.fn(() => false),
},
shell: { openExternal: vi.fn(), showItemInFolder: vi.fn() },
}));
import { createMemberWorkSyncFeature } from '@features/member-work-sync/main';
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter';
import { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaStore';
import { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import { TeamTaskWriter } from '@main/services/team/TeamTaskWriter';
import {
getTasksBasePath,
getTeamsBasePath,
setClaudeBasePathOverride,
} from '@main/utils/pathDecoder';
import {
createOpenCodeLiveHarness,
waitForOpenCodeLanesStopped,
} from './openCodeLiveTestHarness';
const liveDescribe =
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_REVIEW_PICKUP === '1'
? describe
: describe.skip;
const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
const MODEL = process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle';
liveDescribe('OpenCode review pickup live e2e', () => {
let tempDir: string;
let tempClaudeRoot: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-review-pickup-e2e-'));
tempClaudeRoot = path.join(tempDir, '.claude');
await fs.mkdir(tempClaudeRoot, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot);
});
afterEach(async () => {
setClaudeBasePathOverride(null);
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
console.info(`[OpenCodeReviewPickup.live] preserved temp dir: ${tempDir}`);
} else {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it(
'delivers review pickup when the current unread review request is still in the foreground inbox',
async () => {
const harness = await createOpenCodeLiveHarness({
tempDir,
selectedModel: MODEL,
projectPath: PROJECT_PATH,
});
const feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: () => true,
queueQuietWindowMs: 0,
extraBusySignals: [
{
isBusy: (input) => harness.svc.getOpenCodeMemberDeliveryBusyStatus(input),
},
],
reviewPickupDelivery: {
canDeliver: (input) =>
input.providerId === 'opencode'
? { ok: true }
: {
ok: false,
reason: `provider_not_supported:${input.providerId ?? 'unknown'}`,
},
deliver: async (input) => {
const relay = await harness.svc.relayOpenCodeMemberInboxMessages(
input.teamName,
input.memberName,
{
onlyMessageId: input.messageId,
source: 'member-work-sync-review-pickup',
deliveryMetadata: {
actionMode: input.payload.actionMode,
taskRefs: input.payload.taskRefs,
},
}
);
const lastDelivery = relay.lastDelivery;
const diagnostics = [
...(relay.diagnostics ?? []),
...(lastDelivery?.diagnostics ?? []),
];
if (lastDelivery?.accepted === true && lastDelivery.responsePending === true) {
return {
ok: true,
state: 'prompt_accepted' as const,
messageId: input.messageId,
diagnostics,
};
}
if (lastDelivery?.delivered && lastDelivery.accepted !== false) {
return {
ok: true,
state: lastDelivery.responsePending
? ('prompt_accepted' as const)
: ('response_proven' as const),
messageId: input.messageId,
diagnostics,
};
}
return {
ok: false,
reason:
lastDelivery?.ledgerStatus === 'failed_terminal'
? ('terminal_failure' as const)
: ('retryable_failure' as const),
message: lastDelivery?.reason ?? 'opencode_review_pickup_delivery_not_confirmed',
diagnostics,
};
},
},
});
const teamName = `opencode-review-pickup-${Date.now()}`;
const memberName = 'bob';
const taskId = '7142f765-76e5-4532-8a37-e228b841a6ed';
const displayId = '7142f765';
try {
const progressEvents: Array<{ message?: string }> = [];
await harness.svc.createTeam(
{
teamName,
cwd: PROJECT_PATH,
providerId: 'opencode',
model: MODEL,
skipPermissions: true,
members: [{ name: memberName, role: 'Reviewer', providerId: 'opencode', model: MODEL }],
},
(progress) => {
progressEvents.push(progress);
}
);
expect(
progressEvents.some((progress) =>
String(progress.message ?? '').includes('OpenCode team launch is ready')
),
JSON.stringify(progressEvents, null, 2)
).toBe(true);
const createdAt = new Date().toISOString();
await new TeamTaskWriter().createTask(teamName, {
id: taskId,
displayId,
subject: 'Live review pickup e2e task',
description: 'Verify review-pickup delivery over its own unread review request.',
owner: 'alice',
createdBy: 'lead',
status: 'completed',
reviewState: 'review',
projectPath: PROJECT_PATH,
createdAt,
updatedAt: createdAt,
});
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const task = JSON.parse(await fs.readFile(taskPath, 'utf8'));
task.historyEvents = [
...(Array.isArray(task.historyEvents) ? task.historyEvents : []),
{
id: 'evt-live-review-request',
type: 'review_requested',
timestamp: new Date(Date.now() + 1000).toISOString(),
from: 'approved',
to: 'review',
reviewer: memberName,
},
];
task.updatedAt = new Date().toISOString();
await fs.writeFile(taskPath, `${JSON.stringify(task, null, 2)}\n`, 'utf8');
await new TeamInboxWriter().sendMessage(teamName, {
member: memberName,
from: 'team-lead',
to: memberName,
messageId: 'live-review-request-without-taskrefs',
source: 'system_notification',
summary: `Review request for #${displayId}`,
text: [
`**Please review** task #${displayId}`,
'',
'FIRST call review_start to signal you are beginning the review:',
`{ teamName: "${teamName}", taskId: "${taskId}", from: "<your-name>" }`,
].join('\n'),
});
const status = await feature.refreshStatus({ teamName, memberName });
expect(status.state).toBe('needs_sync');
expect(status.agenda.items[0]).toMatchObject({
taskId,
kind: 'review',
evidence: {
reviewObligation: 'review_pickup_required',
reviewRequestEventId: 'evt-live-review-request',
},
});
const taskRef = { teamName, taskId, displayId };
await expect(
harness.svc.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName,
nowIso: new Date().toISOString(),
workSyncIntent: 'review_pickup',
taskRefs: [taskRef],
})
).resolves.toEqual({ busy: false });
const outboxPath = path.join(
getTeamsBasePath(),
teamName,
'members',
memberName,
'.member-work-sync',
'outbox.json'
);
const reconciledBefore = feature.getQueueDiagnostics().reconciled;
feature.noteTeamChange({
type: 'member-turn-settled',
teamName,
detail: JSON.stringify({
memberName,
sourceId: 'review-pickup-live-e2e',
provider: 'opencode',
}),
});
await waitForQueueReconciled(feature, reconciledBefore + 1, 45_000);
const reviewItem = await waitForReviewPickupOutboxDelivery(outboxPath, 180_000);
expect(reviewItem).toMatchObject({
status: 'delivered',
});
expect(reviewItem?.lastError).not.toBe('member_busy:opencode_foreground_inbox_unread');
} finally {
await feature.dispose().catch(() => undefined);
await harness.svc.stopTeam(teamName).catch(() => undefined);
await harness.dispose().catch(() => undefined);
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
}
},
300_000
);
});
async function waitForQueueReconciled(
feature: ReturnType<typeof createMemberWorkSyncFeature>,
expectedReconciled: number,
timeoutMs: number
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let diagnostics = feature.getQueueDiagnostics();
while (Date.now() < deadline) {
diagnostics = feature.getQueueDiagnostics();
if (diagnostics.reconciled >= expectedReconciled) {
return;
}
if (diagnostics.failed > 0 && diagnostics.queued === 0 && diagnostics.running === 0) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(
`Timed out waiting for member-work-sync queue reconcile. Diagnostics: ${JSON.stringify(
diagnostics,
null,
2
)}`
);
}
async function waitForReviewPickupOutboxDelivery(
outboxPath: string,
timeoutMs: number
): Promise<{ status?: string; deliveryState?: string; lastError?: string }> {
const deadline = Date.now() + timeoutMs;
let lastOutbox: unknown = null;
while (Date.now() < deadline) {
try {
const outbox = JSON.parse(await fs.readFile(outboxPath, 'utf8'));
lastOutbox = outbox;
const reviewItem = Object.values(outbox.items ?? outbox).find(
(entry) =>
(entry as { payload?: { workSyncIntent?: string } }).payload?.workSyncIntent ===
'review_pickup'
) as { status?: string; deliveryState?: string; lastError?: string } | undefined;
if (reviewItem?.status === 'delivered') {
return reviewItem;
}
if (
reviewItem?.status === 'failed_terminal' ||
reviewItem?.lastError === 'member_busy:opencode_foreground_inbox_unread'
) {
throw new Error(`Review pickup failed: ${JSON.stringify(reviewItem, null, 2)}`);
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
throw new Error(
`Timed out waiting for review pickup outbox delivery. Last outbox: ${JSON.stringify(
lastOutbox,
null,
2
)}`
);
}

View file

@ -12,6 +12,7 @@ import {
getOpenCodeTeamRuntimeDirectory, getOpenCodeTeamRuntimeDirectory,
inspectOpenCodeRuntimeLaneStorage, inspectOpenCodeRuntimeLaneStorage,
migrateLegacyOpenCodeRuntimeState, migrateLegacyOpenCodeRuntimeState,
prepareOpenCodeRuntimeLaneForLaunchGeneration,
readCommittedOpenCodeBootstrapSessionEvidence, readCommittedOpenCodeBootstrapSessionEvidence,
readOpenCodeRuntimeLaneIndex, readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry, recoverStaleOpenCodeRuntimeLaneIndexEntry,
@ -690,3 +691,330 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => {
}); });
}); });
}); });
describe('prepareOpenCodeRuntimeLaneForLaunchGeneration', () => {
let tempDir: string;
const teamName = 'team-launch-generation';
const laneId = 'secondary:opencode:bob';
const now = new Date('2026-05-09T10:00:00.000Z');
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-runtime-generation-'));
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
async function writeSessionStoreForRun(runId: string): Promise<void> {
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
(candidate) => candidate.schemaName === 'opencode.sessionStore'
);
if (!descriptor) throw new Error('session descriptor missing');
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
const runtimeDirectory = path.dirname(manifestPath);
await fs.mkdir(runtimeDirectory, { recursive: true });
const writer = new RuntimeStoreBatchWriter(
runtimeDirectory,
createRuntimeStoreManifestStore({
filePath: manifestPath,
teamName,
clock: () => now,
}),
createRuntimeStoreReceiptStore({
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
}),
{
clock: () => now,
batchIdFactory: () => `batch-${runId}`,
receiptIdFactory: () => `receipt-${runId}`,
}
);
await writer.writeBatch({
teamName,
runId,
capabilitySnapshotId: null,
behaviorFingerprint: null,
reason: 'launch_checkpoint',
writes: [
{
descriptor,
data: {
sessions: [
{
id: `session-${runId}`,
teamName,
memberName: 'bob',
runId,
laneId,
providerId: 'opencode',
source: 'runtime_bootstrap_checkin',
observedAt: now.toISOString(),
},
],
},
},
],
});
}
async function readManifest() {
return createRuntimeStoreManifestStore({
filePath: getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId),
teamName,
}).read();
}
it('creates a fresh active manifest when the lane has no manifest', async () => {
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 0,
entries: [],
});
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
lanes: {
[laneId]: {
laneId,
state: 'active',
},
},
});
expect(result).toMatchObject({ reset: false, reason: 'fresh_manifest_created' });
});
it('reuses a same-generation manifest without clearing runtime evidence', async () => {
await writeSessionStoreForRun('run-current');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-current',
clock: () => now,
});
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-current',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-current',
highWatermark: 1,
entries: [expect.objectContaining({ runId: 'run-current' })],
});
await expect(
fs.readFile(
getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: tempDir,
teamName,
laneId,
fileName: 'opencode-sessions.json',
}),
'utf8'
)
).resolves.toContain('session-run-current');
expect(result).toMatchObject({ reset: false, reason: 'same_generation_reused' });
});
it('resets runtime evidence when activeRunId belongs to an older run', async () => {
await writeSessionStoreForRun('run-old');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-old',
clock: () => now,
});
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 0,
entries: [],
});
await expect(
fs.readFile(
getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: tempDir,
teamName,
laneId,
fileName: 'opencode-sessions.json',
}),
'utf8'
)
).rejects.toThrow();
expect(result).toMatchObject({ reset: true, reason: 'active_run_mismatch' });
});
it('resets when manifest entries belong to an older run even if activeRunId was advanced', async () => {
await writeSessionStoreForRun('run-old');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
clock: () => now,
});
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 0,
entries: [],
});
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
});
it('resets entries without a run id because they cannot prove the current generation', async () => {
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(
manifestPath,
`${JSON.stringify(
{
schemaVersion: 1,
updatedAt: now.toISOString(),
data: {
schemaVersion: 1,
teamName,
activeRunId: 'run-new',
activeCapabilitySnapshotId: null,
activeBehaviorFingerprint: null,
highWatermark: 1,
lastCommittedBatchId: null,
lastPreparingBatchId: null,
entries: [
{
schemaName: 'opencode.runtimeDiagnostics',
schemaVersion: 1,
relativePath: 'opencode-diagnostics.json',
contentHash: null,
fileSize: null,
mtimeMs: null,
runId: null,
capabilitySnapshotId: null,
behaviorFingerprint: null,
lastWriteReceiptId: null,
state: 'healthy',
},
],
lastRecoveryPlanId: null,
updatedAt: now.toISOString(),
},
},
null,
2
)}\n`,
'utf8'
);
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 0,
entries: [],
});
expect(result).toMatchObject({ reset: true, reason: 'stale_manifest_entries' });
});
it('resets unreadable manifests safely', async () => {
const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId);
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.writeFile(manifestPath, '{not-json', 'utf8');
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-new',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-new',
highWatermark: 0,
entries: [],
});
expect(result).toMatchObject({ reset: true, reason: 'manifest_unreadable' });
});
it('resets degraded or stopped lane index state before launch', async () => {
await writeSessionStoreForRun('run-current');
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-current',
clock: () => now,
});
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempDir,
teamName,
laneId,
state: 'degraded',
diagnostics: ['previous launch failed'],
});
const result = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: tempDir,
teamName,
laneId,
runId: 'run-current',
reason: 'test_launch',
clock: () => now,
});
await expect(readManifest()).resolves.toMatchObject({
activeRunId: 'run-current',
highWatermark: 0,
entries: [],
});
await expect(readOpenCodeRuntimeLaneIndex(tempDir, teamName)).resolves.toMatchObject({
lanes: {
[laneId]: {
laneId,
state: 'active',
},
},
});
expect(result).toMatchObject({ reset: true, reason: 'lane_index_terminal' });
});
});

View file

@ -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 () => { it('keeps OpenCode inbox relay unread when attachment payload data is unavailable', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(); const sendMessageToMember = vi.fn();
@ -12525,7 +12601,7 @@ describe('TeamProvisioningService', () => {
providerBackendId: 'codex-native', providerBackendId: 'codex-native',
model: 'gpt-5.4', model: 'gpt-5.4',
}, },
() => {} vi.fn()
); );
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith( expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
@ -13063,6 +13139,302 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
it('resets stale OpenCode lane manifests before launch and retries exact stale watermark once', async () => {
allowConsoleLogs();
const teamName = 'safe-mixed-opencode-stale-manifest-recovery';
const laneId = 'secondary:opencode:bob';
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId: 'old-opencode-run',
sessions: [
{
id: 'old-session-bob',
teamName,
memberName: 'bob',
laneId,
runId: 'old-opencode-run',
source: 'runtime_bootstrap_checkin',
},
],
});
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempTeamsBase,
teamName,
laneId,
runId: 'old-opencode-run',
});
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
const runId = String(input.runId);
const manifest = await createRuntimeStoreManifestStore({
filePath: getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId),
teamName,
}).read();
expect(manifest).toMatchObject({
activeRunId: runId,
highWatermark: 0,
entries: [],
});
if (adapterLaunch.mock.calls.length === 1) {
throw new Error('OpenCode bridge failed: Bridge server runtime manifest high watermark is stale');
}
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId,
sessions: [
{
id: 'fresh-session-bob',
teamName,
memberName: 'bob',
laneId,
runId,
source: 'runtime_bootstrap_checkin',
},
],
});
return {
runId,
teamName,
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
bob: {
memberName: 'bob',
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: [],
},
},
warnings: [],
diagnostics: [],
};
});
const { svc } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
const { runId } = await svc.createTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax/m2.5',
},
],
},
() => {}
);
const run = (svc as any).runs.get(runId);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
await vi.waitFor(
async () => {
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
expect(publicStatuses.statuses.bob).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
});
},
{ timeout: 5_000 }
);
await svc.cancelProvisioning(runId);
});
it('keeps stale OpenCode lane manifest recovery bounded when the bridge stays stale', async () => {
allowConsoleLogs();
const teamName = 'safe-mixed-opencode-stale-manifest-terminal';
const laneId = 'secondary:opencode:bob';
const staleWatermarkError =
'OpenCode bridge failed: Bridge server runtime manifest high watermark is stale';
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
await writeCommittedOpenCodeSessionStore({
teamName,
laneId,
runId: 'old-opencode-run',
sessions: [
{
id: 'old-session-bob',
teamName,
memberName: 'bob',
laneId,
runId: 'old-opencode-run',
source: 'runtime_bootstrap_checkin',
},
],
});
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempTeamsBase,
teamName,
laneId,
runId: 'old-opencode-run',
});
const adapterLaunch = vi.fn(async () => {
throw new Error(staleWatermarkError);
});
const { svc } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
const { runId } = await svc.createTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax/m2.5',
},
],
},
() => {}
);
const run = (svc as any).runs.get(runId);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 });
await vi.waitFor(
async () => {
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
expect(publicStatuses.statuses.bob).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
});
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(staleWatermarkError);
},
{ timeout: 5_000 }
);
await svc.cancelProvisioning(runId);
});
it('does not retry non-stale OpenCode provider launch failures as manifest recovery', async () => {
allowConsoleLogs();
const teamName = 'safe-mixed-opencode-provider-failure-no-stale-retry';
const providerError =
'OpenCode quota exhausted. This request requires more credits, or fewer max_tokens.';
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const adapterLaunch = vi.fn(async () => {
throw new Error(providerError);
});
const { svc } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
const { runId } = await svc.createTeam(
{
teamName,
cwd: tempClaudeRoot,
providerId: 'codex',
providerBackendId: 'codex-native',
model: 'gpt-5.4',
effort: 'medium',
members: [
{
name: 'alice',
role: 'Reviewer',
providerId: 'codex',
model: 'gpt-5.4-mini',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax/m2.5',
},
],
},
() => {}
);
const run = (svc as any).runs.get(runId);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(1), { timeout: 5_000 });
await vi.waitFor(
async () => {
const publicStatuses = await svc.getMemberSpawnStatuses(teamName);
expect(publicStatuses.statuses.bob).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
});
expect(JSON.stringify(publicStatuses.statuses.bob)).toContain(providerError);
},
{ timeout: 5_000 }
);
await svc.cancelProvisioning(runId);
});
it('restores missing OpenCode teammates into config before post-launch registration audit', async () => { it('restores missing OpenCode teammates into config before post-launch registration audit', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'mixed-opencode-post-launch-config'; const teamName = 'mixed-opencode-post-launch-config';

View file

@ -2131,7 +2131,7 @@ Messages:
`/mock/teams/${teamName}/config.json`, `/mock/teams/${teamName}/config.json`,
JSON.stringify({ JSON.stringify({
name: teamName, name: teamName,
projectPath: '/tmp/my-team', projectPath: '/mock/my-team',
members: [ members: [
{ name: 'team-lead', agentType: 'team-lead' }, { name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, { 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 () => { it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
const service = new TeamProvisioningService(); const service = new TeamProvisioningService();
const teamName = 'my-team'; const teamName = 'my-team';

View file

@ -47,7 +47,10 @@ async function writeTaskFile(baseDir: string, taskId: string, projectPath: strin
function createLedgerBackedChangeExtractorService(params: { function createLedgerBackedChangeExtractorService(params: {
projectDir: string; projectDir: string;
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> }; taskChangePresenceRepository?: {
upsertEntry: ReturnType<typeof vi.fn>;
deleteEntry?: ReturnType<typeof vi.fn>;
};
teamLogSourceTracker?: { teamLogSourceTracker?: {
ensureTracking: ReturnType< ensureTracking: ReturnType<
typeof vi.fn< typeof vi.fn<
@ -752,7 +755,7 @@ describe('task change ledger golden fixtures', () => {
expect(computeTaskChanges).not.toHaveBeenCalled(); 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'); const fixture = await materializeTaskChangeLedgerFixture('notices-only');
cleanups.push(fixture.cleanup); cleanups.push(fixture.cleanup);
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-')); const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
@ -762,14 +765,17 @@ describe('task change ledger golden fixtures', () => {
setClaudeBasePathOverride(claudeBaseDir); setClaudeBasePathOverride(claudeBaseDir);
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir); await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
const upsertEntry = vi.fn(async () => undefined); const upsertEntry = vi.fn(() => Promise.resolve(undefined));
const ensureTracking = vi.fn(async () => ({ const deleteEntry = vi.fn(() => Promise.resolve(undefined));
projectFingerprint: 'fixture-project-fingerprint', const ensureTracking = vi.fn(() =>
logSourceGeneration: 'fixture-log-generation', Promise.resolve({
})); projectFingerprint: 'fixture-project-fingerprint',
logSourceGeneration: 'fixture-log-generation',
})
);
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({ const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
projectDir: fixture.projectDir, projectDir: fixture.projectDir,
taskChangePresenceRepository: { upsertEntry }, taskChangePresenceRepository: { upsertEntry, deleteEntry },
teamLogSourceTracker: { ensureTracking }, teamLogSourceTracker: { ensureTracking },
}); });
@ -784,16 +790,7 @@ describe('task change ledger golden fixtures', () => {
'Task change ledger skipped attribution because multiple task scopes were active.' 'Task change ledger skipped attribution because multiple task scopes were active.'
); );
expect(findLogFileRefsForTask).not.toHaveBeenCalled(); expect(findLogFileRefsForTask).not.toHaveBeenCalled();
expect(upsertEntry).toHaveBeenCalledWith( expect(upsertEntry).not.toHaveBeenCalled();
TEAM_NAME, expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, fixture.manifest.taskId);
expect.objectContaining({
projectFingerprint: 'fixture-project-fingerprint',
logSourceGeneration: 'fixture-log-generation',
}),
expect.objectContaining({
taskId: fixture.manifest.taskId,
presence: 'needs_attention',
})
);
}); });
}); });

View file

@ -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 store = createSliceStore();
const teamName = 'team-a'; const teamName = 'team-a';
const taskId = 'presence-warning'; const taskId = 'presence-warning';
@ -328,17 +328,115 @@ describe('changeReviewSlice task changes', () => {
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); 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( expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName, teamName,
taskId, taskId,
'needs_attention' 'needs_attention'
); );
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('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 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 () => { 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; 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 () => { it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
const store = createSliceStore(); const store = createSliceStore();
const teamName = 'team-revalidate'; const teamName = 'team-revalidate';

View file

@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import {
getAttachmentInputAcceptForMember,
getMemberAttachmentUnavailableReason,
validateAttachmentFilesForMember,
validateAttachmentPayloadsForMember,
} from '../../../src/renderer/utils/attachmentRecipientCapabilities';
import type { AttachmentPayload, ResolvedTeamMember } from '../../../src/shared/types';
function member(overrides: Partial<ResolvedTeamMember>): ResolvedTeamMember {
return {
name: 'bob',
status: 'idle',
currentTaskId: null,
taskCount: 0,
lastActiveAt: null,
messageCount: 0,
...overrides,
};
}
function file(name: string, type: string, bytes = 12): File {
return new File([new Uint8Array(bytes)], name, { type });
}
function payload(overrides: Partial<AttachmentPayload>): AttachmentPayload {
return {
id: 'att-1',
filename: 'diagram.png',
mimeType: 'image/png',
size: 12,
data: 'aW1n',
...overrides,
};
}
describe('attachmentRecipientCapabilities', () => {
it('blocks OpenCode non-vision models before file selection or send', () => {
const bob = member({
providerId: 'opencode',
model: 'openrouter/z-ai/glm-5.1',
});
expect(getMemberAttachmentUnavailableReason(bob)).toBe(
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBe(
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBe(
'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.'
);
});
it('allows image picker input for verified OpenCode vision models', () => {
const bob = member({
providerId: 'opencode',
model: 'openrouter/moonshotai/kimi-k2.6',
});
expect(getMemberAttachmentUnavailableReason(bob)).toBeNull();
expect(getAttachmentInputAcceptForMember(bob)).toBe('image/png,image/jpeg,image/webp');
expect(validateAttachmentFilesForMember({ member: bob, files: [file('diagram.png', 'image/png')] })).toBeNull();
expect(validateAttachmentPayloadsForMember({ member: bob, attachments: [payload({})] })).toBeNull();
});
it('blocks non-image files for image-only providers', () => {
const codexLead = member({
name: 'lead',
agentType: 'team-lead',
providerId: 'codex',
model: 'gpt-5.5',
});
expect(validateAttachmentFilesForMember({ member: codexLead, files: [file('notes.md', 'text/markdown')] })).toBe(
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
);
expect(
validateAttachmentPayloadsForMember({
member: codexLead,
attachments: [payload({ filename: 'notes.md', mimeType: 'text/plain' })],
})
).toBe(
'This provider path currently supports image attachments only. Non-image files are blocked before provider delivery.'
);
});
it('allows text/PDF files for Anthropic lead recipients', () => {
const anthropicLead = member({
name: 'lead',
agentType: 'team-lead',
providerId: 'anthropic',
model: 'claude-opus-4-6',
});
expect(validateAttachmentFilesForMember({ member: anthropicLead, files: [file('brief.pdf', 'application/pdf')] })).toBeNull();
expect(
validateAttachmentPayloadsForMember({
member: anthropicLead,
attachments: [payload({ filename: 'brief.pdf', mimeType: 'application/pdf' })],
})
).toBeNull();
});
});

View file

@ -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.' '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.'
);
});
}); });