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
Most team issues fall into one of five buckets: runtime setup, launch confirmation, task parsing, provider limits, and review state gaps.
Most team issues fall into one of four buckets: runtime setup, launch confirmation, task parsing, and provider limits.
## Team does not launch
@ -12,6 +12,10 @@ Check each item in order:
4. **Project path** — the project directory exists and is readable
5. **Network / VPN** — some providers drop traffic when a VPN is active
::: tip
Run the runtime binary in a terminal to verify `PATH` and auth. Example: `claude --version` or `opencode --version`.
:::
### OpenCode: registered but bootstrap unconfirmed
If OpenCode shows `registered` but bootstrap is unconfirmed, inspect artifacts first before changing team prompts.
@ -83,6 +87,12 @@ If the CLI is authenticated in one terminal but the app says it is not, verify t
- Double-check the provider name in `config.json` matches the provider prefix in the model string
- Ensure the key is not expired or revoked in the provider dashboard
### Auth diagnostic log
Each call to `CliInstallerService.getStatus()` appends one line to `claude-cli-auth-diag.ndjson` in the Electron log folder (usually `~/Library/Logs/<product-name>/` on macOS). If the file exceeds **512 KiB**, it is truncated to empty before the next write.
Check this file if you see "Not logged in" or auth errors in the packaged app.
## Lane bootstrap stuck
For OpenCode secondary lanes:
@ -95,6 +105,41 @@ For OpenCode secondary lanes:
If the bridge says bootstrap succeeded but `manifest.json` shows `entries: []`, the issue is **evidence commit**, not model behavior. The member must not be considered deliverable until `opencode-sessions.json` and its manifest entry exist.
## Common member states
| State | Meaning |
| --- | --- |
| `confirmed_alive` + `bootstrapConfirmed` | Healthy and ready |
| `registered` / `runtime_pending_bootstrap` | Process or lane exists, but bootstrap proof has not been committed yet |
| `failed_to_start` + `runtime_process` | Process exists, but launch gate failed. Check diagnostics |
| `failed_to_start` + `stale_metadata` | Saved pid/session is stale or dead |
::: warning
`member_briefing` by itself is NOT runtime evidence. For OpenCode, authoritative proof is committed runtime evidence such as `opencode-sessions.json` and the manifest entry.
:::
## Runtime debug mode
For local debugging, you can force teammates to run in tmux panes:
```bash
# Launch from a terminal
CLAUDE_TEAM_TEAMMATE_MODE=tmux pnpm dev
# Or add to custom CLI args
--teammate-mode tmux
```
Use this to inspect interactive CLI behavior. Do not consider this fully equivalent to the process backend.
## Safe cleanup
When cleaning up stale processes:
1. Identify the pid and confirm it belongs to the current team / lane.
2. Stop only processes explicitly belonging to a smoke test or the launch you are debugging.
3. **Do not kill** all OpenCode or shared host processes as a shortcut.
## When to collect evidence
Before asking for help, collect:
@ -107,3 +152,7 @@ Before asking for help, collect:
- Exact time window when the issue occurred
This data is usually enough to debug launch and task lifecycle issues.
::: tip
If the issue persists, open the team's persisted files under `~/.claude/teams/<teamName>/` and correlate UI diagnostics with the live process state before changing code.
:::

View file

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

View file

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

View file

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

View file

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

View file

@ -100,6 +100,7 @@ import {
import { shouldSuppressDesktopNotificationForInboxText } from '@shared/utils/idleNotificationSemantics';
import { parseInboxJson } from '@shared/utils/inboxNoise';
import { createLogger } from '@shared/utils/logger';
import { isReviewPickupEscalationMessage } from '@shared/utils/teamAutomationMessages';
import { isTeamInternalControlMessageEnvelope } from '@shared/utils/teamInternalControlMessages';
import { createHash } from 'crypto';
import { app, BrowserWindow, ipcMain } from 'electron';
@ -568,6 +569,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
// Skip app-owned private bootstrap/control prompts. They are durable runtime proof inputs,
// not user-visible conversation messages.
if (isTeamInternalControlMessageEnvelope(msg)) continue;
// Skip internal review-pickup escalations. They are control-plane signals to the lead runtime,
// not user-facing inbox messages.
if (isReviewPickupEscalationMessage(msg)) continue;
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
if (shouldSuppressDesktopNotificationForInboxText(msg.text)) continue;

View file

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

View file

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

View file

@ -1,5 +1,9 @@
import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import {
createTaskChangeDiagnosticFromWarning,
mergeTaskChangeReviewDiagnostics,
} from '@shared/utils/taskChangeReviewability';
import { createHash } from 'crypto';
import { diffLines } from 'diff';
import { open, readFile } from 'fs/promises';
@ -14,6 +18,7 @@ import type {
SnippetDiff,
TaskChangeJournalStamp,
TaskChangeProvenance,
TaskChangeReviewDiagnostic,
TaskChangeScope,
TaskChangeSetV2,
} from '@shared/types';
@ -847,6 +852,7 @@ export class TaskChangeLedgerReader {
provenance: TaskChangeProvenance;
extraWarnings?: string[];
}): TaskChangeSetV2 {
const warnings = [...params.bundle.warnings, ...(params.extraWarnings ?? [])];
return {
teamName: params.teamName,
taskId: params.taskId,
@ -857,7 +863,14 @@ export class TaskChangeLedgerReader {
confidence: params.bundle.confidence,
computedAt: params.bundle.generatedAt,
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files),
warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])],
warnings,
reviewDiagnostics: this.withSummaryStateDiagnostics(
this.buildReviewDiagnosticsFromWarnings(warnings, 'ledger'),
{
integrity: params.bundle.integrity,
diffStatCompleteness: params.bundle.diffStatCompleteness,
}
),
diffStatCompleteness: params.bundle.diffStatCompleteness,
provenance: params.provenance,
};
@ -878,6 +891,13 @@ export class TaskChangeLedgerReader {
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered,
});
const reviewDiagnostics = this.collectReviewDiagnostics(
projectedEvents,
params.journal.notices,
{
recovered: params.journal.recovered,
}
);
let files: FileChangeSummary[];
let totalLinesAdded: number;
@ -930,8 +950,12 @@ export class TaskChangeLedgerReader {
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial';
warnings.push(
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'
const fallbackWarning =
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.';
warnings.push(fallbackWarning);
this.addReviewDiagnostic(
reviewDiagnostics,
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
);
}
@ -946,6 +970,10 @@ export class TaskChangeLedgerReader {
computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
scope,
warnings,
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
integrity: params.bundle?.integrity ?? params.provenance.integrity,
diffStatCompleteness,
}),
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
provenance: params.provenance,
};
@ -967,6 +995,27 @@ export class TaskChangeLedgerReader {
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
const grouped = this.groupSnippets(snippets);
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
const fallbackWarning = 'Task change summary fell back to journal reconstruction.';
const warnings = [
...this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered,
}),
fallbackWarning,
];
const reviewDiagnostics = this.collectReviewDiagnostics(
projectedEvents,
params.journal.notices,
{
recovered: params.journal.recovered,
}
);
this.addReviewDiagnostic(
reviewDiagnostics,
createTaskChangeDiagnosticFromWarning(fallbackWarning, 'summary')
);
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial';
return {
teamName: params.teamName,
taskId: params.taskId,
@ -986,15 +1035,12 @@ export class TaskChangeLedgerReader {
projectedEvents,
params.journal.notices
),
warnings: [
...this.collectWarnings(projectedEvents, params.journal.notices, {
recovered: params.journal.recovered,
warnings,
reviewDiagnostics: this.withSummaryStateDiagnostics(reviewDiagnostics, {
integrity: provenance.integrity,
diffStatCompleteness,
}),
'Task change summary fell back to journal reconstruction.',
],
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial',
diffStatCompleteness,
provenance,
};
}
@ -1017,6 +1063,10 @@ export class TaskChangeLedgerReader {
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.'
);
for (const notice of params.bundle.notices ?? []) warnings.add(notice.message);
const warningList = [...warnings];
const diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial';
return {
teamName: params.teamName,
@ -1035,10 +1085,12 @@ export class TaskChangeLedgerReader {
params.bundle.events,
params.bundle.notices ?? []
),
warnings: [...warnings],
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
? 'complete'
: 'partial',
warnings: warningList,
reviewDiagnostics: this.withSummaryStateDiagnostics(
this.buildReviewDiagnosticsFromWarnings(warningList, 'ledger'),
{ diffStatCompleteness }
),
diffStatCompleteness,
provenance: {
sourceKind: 'ledger',
sourceFingerprint: this.hashFingerprintPayload({
@ -1478,6 +1530,150 @@ export class TaskChangeLedgerReader {
};
}
private addReviewDiagnostic(
diagnostics: TaskChangeReviewDiagnostic[],
diagnostic: TaskChangeReviewDiagnostic
): void {
const existingIndex = diagnostics.findIndex(
(existing) => existing.code === diagnostic.code && existing.message === diagnostic.message
);
if (existingIndex >= 0) {
const existing = diagnostics[existingIndex];
if (existing) {
diagnostics[existingIndex] = mergeTaskChangeReviewDiagnostics(existing, diagnostic);
}
return;
}
diagnostics.push(diagnostic);
}
private buildReviewDiagnosticsFromWarnings(
warnings: string[],
source: TaskChangeReviewDiagnostic['source']
): TaskChangeReviewDiagnostic[] {
const diagnostics: TaskChangeReviewDiagnostic[] = [];
for (const warning of warnings) {
this.addReviewDiagnostic(diagnostics, createTaskChangeDiagnosticFromWarning(warning, source));
}
return diagnostics;
}
private diagnosticFromNotice(notice: LedgerNotice): TaskChangeReviewDiagnostic {
if (notice.code === 'multi-scope-skipped') {
return {
code: 'multi_scope_no_safe_diff',
severity: 'info',
reviewBlocking: false,
message:
'Activity was observed while multiple task scopes were active, so file edits were not safely assigned to this task.',
source: 'ledger',
};
}
if (notice.code === 'journal-recovered') {
return {
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
source: 'ledger',
};
}
if (notice.code === 'writer-lock-stolen') {
return {
code: 'unsafe_or_untrusted_evidence',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger writer lock changed while evidence was being recorded.',
source: 'ledger',
};
}
return createTaskChangeDiagnosticFromWarning(notice.message, 'ledger');
}
private collectReviewDiagnostics(
events: LedgerEvent[],
notices: LedgerNotice[],
options: { recovered: boolean }
): TaskChangeReviewDiagnostic[] {
const diagnostics: TaskChangeReviewDiagnostic[] = [];
for (const notice of notices) {
this.addReviewDiagnostic(diagnostics, this.diagnosticFromNotice(notice));
}
for (const event of events) {
for (const warning of event.warnings ?? []) {
this.addReviewDiagnostic(
diagnostics,
createTaskChangeDiagnosticFromWarning(warning, 'ledger')
);
}
if (event.toolStatus === 'failed') {
this.addReviewDiagnostic(diagnostics, {
code: 'tool_failed_after_edit',
severity: 'warning',
reviewBlocking: true,
message: `Tool ${event.toolUseId} failed after changing files.`,
source: 'ledger',
});
}
if (event.toolStatus === 'killed') {
this.addReviewDiagnostic(diagnostics, {
code: 'tool_killed_after_edit',
severity: 'warning',
reviewBlocking: true,
message: `Background tool ${event.toolUseId} was killed after changing files.`,
source: 'ledger',
});
}
}
if (options.recovered) {
this.addReviewDiagnostic(diagnostics, {
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
source: 'ledger',
});
}
return diagnostics;
}
private withSummaryStateDiagnostics(
diagnostics: TaskChangeReviewDiagnostic[],
state: {
integrity?: 'ok' | 'recovered' | 'partial';
diffStatCompleteness?: 'complete' | 'partial';
}
): TaskChangeReviewDiagnostic[] {
const next = [...diagnostics];
if (state.diffStatCompleteness === 'partial') {
this.addReviewDiagnostic(next, {
code: 'diff_stat_partial',
severity: 'warning',
reviewBlocking: true,
message: 'Some file change statistics are incomplete.',
source: 'summary',
});
}
if (state.integrity === 'partial') {
this.addReviewDiagnostic(next, {
code: 'ledger_integrity_partial',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger is partially available.',
source: 'ledger',
});
} else if (state.integrity === 'recovered') {
this.addReviewDiagnostic(next, {
code: 'ledger_integrity_recovered',
severity: 'warning',
reviewBlocking: true,
message: 'The task-change ledger was recovered from malformed journal lines.',
source: 'ledger',
});
}
return next;
}
private collectWarnings(
events: LedgerEvent[],
notices: LedgerNotice[],

View file

@ -233,6 +233,7 @@ import {
inspectOpenCodeRuntimeLaneStorage,
migrateLegacyOpenCodeRuntimeState,
OpenCodeRuntimeManifestEvidenceReader,
prepareOpenCodeRuntimeLaneForLaunchGeneration,
readCommittedOpenCodeBootstrapSessionEvidence,
readOpenCodeRuntimeLaneIndex,
recoverStaleOpenCodeRuntimeLaneIndexEntry,
@ -9042,10 +9043,19 @@ export class TeamProvisioningService {
? error.code
: 'opencode_attachment_delivery_prepare_failed';
const diagnostic = `opencode_attachment_delivery_prepare_failed: ${getErrorMessage(error)}`;
const userVisibleMessage =
error instanceof AgentAttachmentError
? error.message
: 'OpenCode could not prepare the attachment for live delivery.';
return {
delivered: false,
reason,
diagnostics: [diagnostic],
userVisibleImpact: {
state: 'error',
reasonCode: 'backend_error',
message: userVisibleMessage,
},
};
}
}
@ -12288,6 +12298,8 @@ export class TeamProvisioningService {
teamName: string;
memberName: string;
nowIso: string;
workSyncIntent?: 'agenda_sync' | 'review_pickup';
taskRefs?: TaskRef[];
}): Promise<{
busy: boolean;
reason?: string;
@ -12318,7 +12330,10 @@ export class TeamProvisioningService {
const foregroundMessages = inboxMessages.filter(
(message) => message.messageKind !== 'member_work_sync_nudge'
);
const unreadForeground = foregroundMessages.find(
const blockingForegroundMessages = foregroundMessages.filter(
(message) => !this.isCurrentReviewPickupRequestForegroundMessage(message, input)
);
const unreadForeground = blockingForegroundMessages.find(
(message) =>
!message.read &&
typeof message.text === 'string' &&
@ -12335,7 +12350,7 @@ export class TeamProvisioningService {
};
}
const recentForeground = foregroundMessages.find((message) => {
const recentForeground = blockingForegroundMessages.find((message) => {
const timestampMs = Date.parse(message.timestamp);
return Number.isFinite(timestampMs) && Number.isFinite(nowMs) && nowMs - timestampMs < 60_000;
});
@ -12441,7 +12456,7 @@ export class TeamProvisioningService {
reasonCode: input.reason
? classifyOpenCodeRuntimeDeliveryReasonCode(input.reason)
: undefined,
message: input.reason,
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
};
}
if (input.delivered === false) {
@ -12453,18 +12468,84 @@ export class TeamProvisioningService {
return {
state: 'checking',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason,
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
};
}
return {
state: 'error',
reasonCode: classifyOpenCodeRuntimeDeliveryReasonCode(reason),
message: reason,
message: this.selectOpenCodeRuntimeDeliveryUserVisibleMessage(input),
};
}
return input.policyImpact ?? { state: 'none' };
}
private selectOpenCodeRuntimeDeliveryUserVisibleMessage(input: {
reason?: string;
diagnostics?: string[];
}): string | undefined {
const attachmentMessage = this.selectOpenCodeAttachmentDeliveryUserVisibleMessage(input);
if (attachmentMessage) {
return attachmentMessage;
}
return input.reason;
}
private selectOpenCodeAttachmentDeliveryUserVisibleMessage(input: {
reason?: string;
diagnostics?: string[];
}): string | undefined {
const reason = input.reason?.trim();
const isAttachmentFailure =
this.isOpenCodeAttachmentDeliveryFailureReason(reason) ||
input.diagnostics?.some((diagnostic) =>
diagnostic.trim().startsWith('opencode_attachment_delivery_prepare_failed:')
) === true;
if (!isAttachmentFailure) {
return undefined;
}
const diagnosticMessage = input.diagnostics
?.map((diagnostic) => diagnostic.trim())
.find((diagnostic) => diagnostic.startsWith('opencode_attachment_delivery_prepare_failed:'));
const strippedDiagnostic = diagnosticMessage
?.slice('opencode_attachment_delivery_prepare_failed:'.length)
.trim();
if (strippedDiagnostic) {
return strippedDiagnostic;
}
if (reason === 'attachment_model_unsupported') {
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
}
if (reason === 'attachment_type_unsupported') {
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
}
if (reason === 'attachment_too_large') {
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
}
if (reason === 'attachment_artifact_missing' || reason === 'attachment_artifact_path_unsafe') {
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
}
if (reason === 'attachment_optimization_failed') {
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
}
if (reason === 'attachment_provider_rejected') {
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
}
if (reason === 'attachment_runtime_transport_failed') {
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
}
return undefined;
}
private isOpenCodeAttachmentDeliveryFailureReason(reason: string | undefined): boolean {
return (
reason === 'opencode_attachment_delivery_prepare_failed' ||
reason?.startsWith('attachment_') === true
);
}
private toOpenCodeRuntimeDeliveryStatus(
record: OpenCodePromptDeliveryLedgerRecord,
decision?: OpenCodeRuntimeDeliveryAdvisoryDecision
@ -21395,8 +21476,9 @@ export class TeamProvisioningService {
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']),
];
if (
delivery.reason !== 'opencode_runtime_not_active' ||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName)
!this.isOpenCodeAttachmentDeliveryFailureReason(delivery.reason) &&
(delivery.reason !== 'opencode_runtime_not_active' ||
!this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName))
) {
logger.warn(
`[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${
@ -21493,6 +21575,57 @@ export class TeamProvisioningService {
return typeof message.messageId === 'string' && message.messageId.trim().length > 0;
}
private isCurrentReviewPickupRequestForegroundMessage(
message: InboxMessage,
input: { workSyncIntent?: 'agenda_sync' | 'review_pickup'; taskRefs?: TaskRef[] }
): boolean {
if (input.workSyncIntent !== 'review_pickup') {
return false;
}
if (message.source !== 'system_notification') {
return false;
}
const expectedRefs = this.normalizeOpenCodeTaskRefsForComparison(input.taskRefs);
if (expectedRefs.length === 0) {
return false;
}
const summary = typeof message.summary === 'string' ? message.summary.trim() : '';
const text = typeof message.text === 'string' ? message.text : '';
const looksLikeReviewRequest =
summary.startsWith('Review request for #') ||
(text.includes('**Please review**') && text.includes('review_start'));
if (!looksLikeReviewRequest) {
return false;
}
const messageRefs = this.normalizeOpenCodeTaskRefsForComparison(message.taskRefs);
if (messageRefs.length > 0) {
const expectedKeys = new Set(expectedRefs.map((taskRef) => this.openCodeTaskRefKey(taskRef)));
return messageRefs.some((taskRef) => expectedKeys.has(this.openCodeTaskRefKey(taskRef)));
}
return expectedRefs.some((taskRef) =>
this.openCodeReviewPickupRequestTextMentionsTask({ summary, text, taskRef })
);
}
private openCodeReviewPickupRequestTextMentionsTask(input: {
summary: string;
text: string;
taskRef: TaskRef;
}): boolean {
const displayId = input.taskRef.displayId.trim();
const taskId = input.taskRef.taskId.trim();
const haystack = `${input.summary}\n${input.text}`;
return (
(displayId.length > 0 &&
(haystack.includes(`#${displayId}`) || haystack.includes(`task #${displayId}`))) ||
(taskId.length > 0 && haystack.includes(taskId))
);
}
private isUserOriginatedLeadRelayMessage(message: InboxMessage): boolean {
const from = typeof message.from === 'string' ? message.from.trim().toLowerCase() : '';
return from === 'user' || message.source === 'user_sent';
@ -25303,12 +25436,13 @@ export class TeamProvisioningService {
lane.state = 'launching';
lane.runId = lane.runId ?? randomUUID();
const laneRunId = lane.runId;
lane.warnings = [];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
this.setSecondaryRuntimeRun({
teamName: run.teamName,
runId: lane.runId,
runId: laneRunId,
providerId: 'opencode',
laneId: lane.laneId,
memberName: lane.member.name,
@ -25322,11 +25456,12 @@ export class TeamProvisioningService {
await finishCancelledLane();
return;
}
await setOpenCodeRuntimeActiveRunManifest({
await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: getTeamsBasePath(),
teamName: run.teamName,
laneId: lane.laneId,
runId: lane.runId,
runId: laneRunId,
reason: 'mixed_secondary_launch',
});
if (shouldAbortLaunch()) {
await finishCancelledLane();
@ -25340,8 +25475,9 @@ export class TeamProvisioningService {
await finishCancelledLane();
return;
}
const rawResult = await adapter.launch({
runId: lane.runId,
const launchOpenCodeLane = () =>
adapter.launch({
runId: laneRunId,
laneId: lane.laneId,
teamName: run.teamName,
cwd: laneCwd,
@ -25365,6 +25501,40 @@ export class TeamProvisioningService {
],
previousLaunchState,
});
let rawResult: TeamRuntimeLaunchResult;
try {
rawResult = await launchOpenCodeLane();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const staleManifestMessage = 'Bridge server runtime manifest high watermark is stale';
if (
message !== staleManifestMessage &&
message !== `OpenCode bridge failed: ${staleManifestMessage}`
) {
throw error;
}
if (shouldAbortLaunch()) {
await finishCancelledLane();
return;
}
const recovery = await prepareOpenCodeRuntimeLaneForLaunchGeneration({
teamsBasePath: getTeamsBasePath(),
teamName: run.teamName,
laneId: lane.laneId,
runId: laneRunId,
reason: 'mixed_secondary_launch_stale_manifest_recovery',
forceReset: true,
});
lane.diagnostics = appendDiagnosticOnce(
[...lane.diagnostics, ...recovery.diagnostics],
'Retried OpenCode secondary launch after resetting stale runtime manifest.'
);
if (shouldAbortLaunch()) {
await finishCancelledLane();
return;
}
rawResult = await launchOpenCodeLane();
}
if (shouldAbortLaunch()) {
await finishCancelledLane();
return;
@ -25467,7 +25637,7 @@ export class TeamProvisioningService {
lane.launchFinishedAtMs = Date.now();
const timingDiagnostic = buildOpenCodeSecondaryLaneTimingDiagnostic(lane);
lane.result = {
runId: lane.runId,
runId: laneRunId,
teamName: run.teamName,
launchPhase: 'finished',
teamLaunchState: 'partial_failure',

View file

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

View file

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

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: {
teamsBasePath: string;
teamName: string;

View file

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

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

View file

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

View file

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

View file

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

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 { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import {
buildTeamChangeRequestPlan,
buildTeamChangesTasksFingerprint,
@ -136,7 +137,9 @@ export function useTeamChangesSummaries({
activeRequestSeqRef.current = requestSeq;
try {
const response = await api.review.getTeamTaskChangeSummaries(teamName, plan.requests);
const response = await withTeamChangesLoadTimeout(
api.review.getTeamTaskChangeSummaries(teamName, plan.requests)
);
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return;
}

View file

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

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.';
const FAILED_WARNING =
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
const ATTACHMENT_FAILED_WARNING =
'OpenCode attachment was not sent. Message was saved to inbox, but live delivery cannot include this attachment.';
function isOpenCodeAttachmentDeliveryFailureReason(reason: string | null | undefined): boolean {
const normalized = reason?.trim().toLowerCase();
return (
normalized === 'opencode_attachment_delivery_prepare_failed' ||
normalized?.startsWith('attachment_') === true ||
normalized?.startsWith('opencode_attachment_delivery_prepare_failed:') === true
);
}
function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
const normalized = reason?.trim();
@ -69,6 +80,33 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (normalizedLower === 'non_visible_tool_without_task_progress') {
return 'OpenCode used tools, but did not create a visible reply or task progress proof.';
}
if (normalizedLower === 'attachment_model_unsupported') {
return 'This OpenCode model is not verified for image attachments. Choose a vision-capable model or remove the image.';
}
if (normalizedLower === 'attachment_type_unsupported') {
return 'This OpenCode model cannot receive this attachment type. Remove the attachment or choose a supported image model.';
}
if (normalizedLower === 'attachment_too_large') {
return 'The attachment is too large for live OpenCode delivery. Reduce the image size or remove the attachment.';
}
if (
normalizedLower === 'attachment_artifact_missing' ||
normalizedLower === 'attachment_artifact_path_unsafe'
) {
return 'The attachment file is not available for live OpenCode delivery. Reattach the file and try again.';
}
if (normalizedLower === 'attachment_optimization_failed') {
return 'The attachment could not be optimized for live OpenCode delivery. Try a smaller image or remove the attachment.';
}
if (normalizedLower === 'attachment_provider_rejected') {
return 'The OpenCode provider rejected the attachment. Choose a different model or remove the attachment.';
}
if (normalizedLower === 'attachment_runtime_transport_failed') {
return 'OpenCode could not transport the attachment to the runtime. Try again or remove the attachment.';
}
if (normalizedLower.startsWith('opencode_attachment_delivery_prepare_failed:')) {
return normalized.slice('opencode_attachment_delivery_prepare_failed:'.length).trim();
}
return '';
}
@ -94,12 +132,16 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
}
const userVisibleMessage = runtimeDelivery.userVisibleImpact?.message?.trim();
const failureReason =
isFailed || isWarning
? formatOpenCodeRuntimeDeliveryFailureReason(
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
)
: '';
const candidateFailureReason =
userVisibleMessage ?? runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0];
const mappedFailureReason =
isFailed || isWarning ? formatOpenCodeRuntimeDeliveryFailureReason(candidateFailureReason) : '';
const failureReason = mappedFailureReason || (isFailed || isWarning ? userVisibleMessage : '');
const isAttachmentFailure =
isFailed &&
(isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.reason) ||
isOpenCodeAttachmentDeliveryFailureReason(runtimeDelivery.diagnostics?.[0]) ||
isOpenCodeAttachmentDeliveryFailureReason(candidateFailureReason));
const statusMessageId = runtimeDelivery.queuedBehindMessageId ?? result.messageId;
return {
@ -108,6 +150,10 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
? `${PROOF_WARNING} Reason: ${failureReason}`
: isWarning
? PROOF_WARNING
: isAttachmentFailure && failureReason
? `${ATTACHMENT_FAILED_WARNING} Reason: ${failureReason}`
: isAttachmentFailure
? ATTACHMENT_FAILED_WARNING
: isFailed && failureReason
? `${FAILED_WARNING} Reason: ${failureReason}`
: isFailed

View file

@ -132,6 +132,65 @@ export interface TaskChangeSet {
computedAt: string;
}
export const TASK_CHANGE_DIAGNOSTIC_CODES = [
'multi_scope_no_safe_diff',
'active_task_no_edits_yet',
'summary_timeout',
'summary_reconstructed',
'journal_unavailable',
'ledger_integrity_recovered',
'ledger_integrity_partial',
'ledger_freshness_mismatch',
'diff_stat_partial',
'tool_failed_after_edit',
'tool_killed_after_edit',
'unsafe_or_untrusted_evidence',
'legacy_warning',
] as const;
export type TaskChangeDiagnosticCode = (typeof TASK_CHANGE_DIAGNOSTIC_CODES)[number];
export type TaskChangeDiagnosticSeverity = 'info' | 'warning' | 'error';
export interface TaskChangeReviewDiagnostic {
code: TaskChangeDiagnosticCode;
severity: TaskChangeDiagnosticSeverity;
reviewBlocking: boolean;
message: string;
source?: 'ledger' | 'legacy' | 'summary' | 'runtime';
}
export type TaskChangeReviewability =
| 'reviewable'
| 'attention_required'
| 'diagnostic_only'
| 'none'
| 'unknown';
export type TaskChangeReviewAction =
| 'review_diff'
| 'inspect_diagnostics'
| 'wait_or_refresh'
| 'nothing';
export type TaskChangeReviewReasonCode =
| 'files_changed'
| 'files_changed_with_non_blocking_diagnostics'
| 'diagnostic_only'
| 'confirmed_no_changes'
| 'pending_no_edits_yet'
| 'blocking_diagnostics'
| 'low_confidence';
export interface TaskChangeReviewabilityStatus {
reviewability: TaskChangeReviewability;
reasonCode: TaskChangeReviewReasonCode;
userAction: TaskChangeReviewAction;
severity: 'success' | 'warning' | 'info' | 'none';
message: string;
diagnostics: TaskChangeReviewDiagnostic[];
}
/** Краткая статистика для badge */
export interface ChangeStats {
linesAdded: number;
@ -287,6 +346,7 @@ export interface TaskBoundariesResult {
export interface TaskChangeSetV2 extends TaskChangeSet {
scope: TaskChangeScope;
warnings: string[];
reviewDiagnostics?: TaskChangeReviewDiagnostic[];
diffStatCompleteness?: 'complete' | 'partial';
provenance?: TaskChangeProvenance;
}

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';
const EMPTY_INTERVAL_NO_EDITS_WARNING = 'No file edits found within persisted workIntervals.';
function isBenignActiveIntervalWithoutFileEdits(
data: Pick<TaskChangeSetV2, 'files' | 'warnings' | 'scope'>
): boolean {
if (data.files.length > 0) {
return false;
}
if (data.warnings.length !== 1 || data.warnings[0] !== EMPTY_INTERVAL_NO_EDITS_WARNING) {
return false;
}
return Boolean(data.scope.startTimestamp) && !data.scope.endTimestamp && data.scope.toolUseIds.length === 0;
}
export function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence' | 'warnings' | 'scope'>
data: Pick<TaskChangeSetV2, 'files' | 'totalFiles' | 'confidence' | 'warnings' | 'scope'> &
Partial<Pick<TaskChangeSetV2, 'diffStatCompleteness' | 'provenance' | 'reviewDiagnostics'>>
): Exclude<TaskChangePresenceState, 'unknown'> | null {
if (data.files.length > 0) {
const status = classifyTaskChangeReviewability(data);
switch (status.reviewability) {
case 'reviewable':
return 'has_changes';
}
if (isBenignActiveIntervalWithoutFileEdits(data)) {
case 'attention_required':
return 'needs_attention';
case 'none':
return 'no_changes';
case 'diagnostic_only':
case 'unknown':
return null;
}
if ((data.warnings?.length ?? 0) > 0) {
return 'needs_attention';
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
}

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: [
'./src/renderer/index.html',
'./src/renderer/**/*.{js,ts,jsx,tsx}',
'./src/features/**/*.{js,ts,jsx,tsx}',
'./src/shared/**/*.{js,ts,jsx,tsx}',
'./packages/agent-graph/src/**/*.{js,ts,jsx,tsx}'
],

View file

@ -822,6 +822,9 @@ describe('MemberWorkSync use cases', () => {
const inbox = new InMemoryInboxNudge();
const deliveryCalls: Array<Parameters<MemberWorkSyncReviewPickupDeliveryPort['deliver']>[0]> =
[];
const busyCalls: Parameters<
NonNullable<MemberWorkSyncUseCaseDeps['busySignal']>['isBusy']
>[0][] = [];
const reviewPickupDelivery: MemberWorkSyncReviewPickupDeliveryPort = {
canDeliver: async () => ({ ok: true }),
deliver: async (input) => {
@ -840,6 +843,12 @@ describe('MemberWorkSync use cases', () => {
outboxStore: outbox,
inboxNudge: inbox,
reviewPickupDelivery,
busySignal: {
isBusy: (input) => {
busyCalls.push(input);
return Promise.resolve({ busy: false });
},
},
});
await new MemberWorkSyncReconciler(deps).execute(
@ -853,6 +862,15 @@ describe('MemberWorkSync use cases', () => {
expect(summary).toMatchObject({ claimed: 1, delivered: 1, superseded: 0 });
expect(inbox.inserted).toHaveLength(1);
expect(busyCalls).toEqual([
{
teamName: 'team-a',
memberName: 'bob',
nowIso: '2026-04-29T00:00:00.000Z',
workSyncIntent: 'review_pickup',
taskRefs: [{ taskId: 'task-review', displayId: '22222222', teamName: 'team-a' }],
},
]);
expect(deliveryCalls).toHaveLength(1);
expect(deliveryCalls[0]).toMatchObject({
messageId: 'member-work-sync:team-a:bob:review-pickup:evt-review-request',

View file

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

View file

@ -6,6 +6,7 @@ import * as fs from 'fs/promises';
import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository';
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
import { resolveTaskChangePresenceFromResult } from '../../../../src/shared/utils/taskChangePresence';
import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes';
@ -96,6 +97,64 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
).toContain('"teamName": "team-a"');
});
it('preserves review classification metadata when loading cached entries', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
await repo.save(
buildEntry({
summary: {
...buildEntry().summary,
diffStatCompleteness: 'partial',
reviewDiagnostics: [
{
code: 'summary_reconstructed',
severity: 'info',
reviewBlocking: false,
message: 'The change summary was reconstructed from the task-change journal.',
source: 'summary',
},
],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'ledger-fingerprint',
integrity: 'partial',
bundleSchemaVersion: 2,
journalStamp: {
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
},
},
},
})
);
const loaded = await repo.load('team-a', '1');
expect(loaded?.summary.diffStatCompleteness).toBe('partial');
expect(loaded?.summary ? resolveTaskChangePresenceFromResult(loaded.summary) : null).toBe(
'needs_attention'
);
expect(loaded?.summary.reviewDiagnostics).toEqual([
{
code: 'summary_reconstructed',
severity: 'info',
reviewBlocking: false,
message: 'The change summary was reconstructed from the task-change journal.',
source: 'summary',
},
]);
expect(loaded?.summary.provenance).toMatchObject({
sourceKind: 'ledger',
sourceFingerprint: 'ledger-fingerprint',
integrity: 'partial',
bundleSchemaVersion: 2,
journalStamp: {
events: { bytes: 10, mtimeMs: 1000, tailSha256: 'events-tail' },
},
});
});
it('treats expired entries as cache misses', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
@ -110,7 +169,7 @@ describe('JsonTaskChangeSummaryCacheRepository', () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-'));
setClaudeBasePathOverride(tmpDir);
const repo = new JsonTaskChangeSummaryCacheRepository();
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '{bad-json', 'utf8');

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

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

View file

@ -2131,7 +2131,7 @@ Messages:
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
projectPath: '/mock/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
@ -3127,6 +3127,86 @@ Messages:
});
});
it('does not treat the current unread OpenCode review request as busy for review-pickup checks', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const laneId = 'secondary:opencode:jack';
const teamsBasePath = getTeamsBasePath();
hoisted.files.set(
`${teamsBasePath}/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/tmp/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
hoisted.files.set(
`${teamsBasePath}/${teamName}/inboxes/jack.json`,
JSON.stringify([
{
from: 'team-lead',
to: 'jack',
text: '**Please review** task #task1234\n\nFIRST call review_start.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'review-request-1',
source: 'system_notification',
summary: 'Review request for #task1234',
},
])
);
(service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(() =>
Promise.resolve({
ok: true,
canonicalMemberName: 'jack',
laneId,
})
);
vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockReturnValue(
Promise.resolve({
version: 1,
updatedAt: '2026-02-23T17:30:00.000Z',
lanes: {
[laneId]: {
laneId,
state: 'active',
updatedAt: '2026-02-23T17:30:00.000Z',
},
},
})
);
vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({
getActiveForMember: vi.fn(() => Promise.resolve(null)),
});
const busy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName: 'jack',
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'review_pickup',
taskRefs: [{ teamName, taskId: 'task-1234', displayId: 'task1234' }],
});
expect(busy).toEqual({ busy: false });
const mismatchedTaskBusy = await service.getOpenCodeMemberDeliveryBusyStatus({
teamName,
memberName: 'jack',
nowIso: '2026-02-23T17:31:10.000Z',
workSyncIntent: 'review_pickup',
taskRefs: [{ teamName, taskId: 'other-task', displayId: 'other' }],
});
expect(mismatchedTaskBusy).toMatchObject({
busy: true,
reason: 'opencode_foreground_inbox_unread',
activeMessageId: 'review-request-1',
});
});
it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';

View file

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

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 teamName = 'team-a';
const taskId = 'presence-warning';
@ -328,17 +328,115 @@ describe('changeReviewSlice task changes', () => {
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).not.toHaveBeenCalledWith(
teamName,
taskId,
'needs_attention'
);
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
store.getState().invalidateTaskChangePresence([cacheKey]);
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('treats unclassified warning-only summaries as needs_attention', async () => {
const store = createSliceStore();
const teamName = 'team-a';
const taskId = 'presence-unclassified-warning';
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName,
taskId,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'Unknown warning' },
},
warnings: ['Unexpected ledger warning.'],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'ledger-warning-only',
},
});
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName,
taskId,
'needs_attention'
);
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention');
});
it('background revalidates cached needs_attention presence', async () => {
const store = createSliceStore();
const teamName = 'team-a';
const taskId = 'cached-needs-attention';
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
store.setState({
selectedTeamName: teamName,
selectedTeamData: {
tasks: [{ id: taskId, changePresence: 'needs_attention' }],
},
taskChangePresenceByKey: { [cacheKey]: 'needs_attention' },
});
hoisted.getTaskChanges.mockResolvedValue({
teamName,
taskId,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
},
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
});
store.getState().invalidateTaskChangePresence([cacheKey]);
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
await flushAsyncWork();
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
expect(hoisted.getTaskChanges).toHaveBeenCalledWith(teamName, taskId, {
...OPTIONS_A,
summaryOnly: true,
forceFresh: true,
});
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName,
taskId,
'needs_attention'
);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName,
taskId,
'unknown'
);
});
it('does not raise needs_attention for active interval summaries with no observed file edits yet', async () => {
@ -737,6 +835,40 @@ describe('changeReviewSlice task changes', () => {
await warmPromise;
});
it('clears stale no_changes warm cache entries for diagnostic-only summaries', async () => {
const store = createSliceStore();
const teamName = 'team-a';
const taskId = 'warm-diagnostic-only';
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
store.setState({ taskChangePresenceByKey: { [cacheKey]: 'no_changes' } });
hoisted.getTaskChanges.mockResolvedValue({
teamName,
taskId,
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'Multi-scope notice only' },
},
warnings: ['Task change ledger skipped attribution because multiple task scopes were active.'],
});
await store.getState().warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]);
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBeUndefined();
});
it('clears optimistic terminal presence after background forceFresh revalidation', async () => {
const store = createSliceStore();
const teamName = 'team-revalidate';

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