From 80acc3b6632f42636632a67a98e6487c07035e90 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 00:25:55 +0300 Subject: [PATCH] feat(team): harden runtime delivery and diagnostics --- agent-teams-controller/src/internal/review.js | 8 +- .../src/internal/taskStore.js | 16 +- .../test/controller.test.js | 56 +- .../member-log-stream/contracts/api.ts | 7 + .../member-log-stream/contracts/channels.ts | 1 + .../member-log-stream/contracts/dto.ts | 18 + .../member-log-stream/contracts/normalize.ts | 51 + .../registerMemberLogStreamIpc.test.ts | 71 ++ .../input/ipc/registerMemberLogStreamIpc.ts | 89 ++ .../application/MemberRuntimeLogTailReader.ts | 176 ++++ .../MemberRuntimeLogTailReader.test.ts | 104 ++ .../createMemberLogStreamFeature.ts | 27 +- .../createMemberLogStreamBridge.test.ts | 43 + .../preload/createMemberLogStreamBridge.ts | 17 + .../adapters/MemberLogStreamSection.tsx | 73 +- .../ui/MemberRuntimeProcessLogsPanel.tsx | 278 ++++++ src/main/http/teams.ts | 3 + src/main/ipc/teams.ts | 2 + src/main/services/team/TaskChangeComputer.ts | 15 +- src/main/services/team/TeamInboxWriter.ts | 318 +++++- .../services/team/TeamMemberLogsFinder.ts | 56 +- .../team/TeamMemberRuntimeAdvisoryService.ts | 4 + .../services/team/TeamProvisioningService.ts | 834 ++++++++++++++-- .../team/TeamTaskActivityIntervalService.ts | 271 ++++- src/main/services/team/TeamTaskWriter.ts | 2 +- .../bridge/OpenCodeReadinessBridge.ts | 2 +- .../delivery/OpenCodePromptDeliveryLedger.ts | 18 +- .../OpenCodePromptDeliveryRepairPolicy.ts | 14 +- .../OpenCodeRuntimeDeliveryDiagnostics.ts | 15 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 4 + .../team/stallMonitor/TeamTaskStallPolicy.ts | 2 +- .../BoardTaskLogDiagnosticsService.ts | 5 +- .../stream/BoardTaskLogStreamService.ts | 7 +- .../stream/OpenCodeTaskLogStreamSource.ts | 7 +- src/main/services/team/teamTaskActiveState.ts | 2 +- src/renderer/api/httpClient.ts | 5 + .../components/team/members/MemberLogsTab.tsx | 84 +- .../team/messages/MessageComposer.tsx | 4 +- src/renderer/utils/memberActivityTimer.ts | 7 +- src/renderer/utils/memberHelpers.ts | 9 + .../openCodeRuntimeDeliveryDiagnostics.ts | 9 + src/shared/utils/taskWorkDuration.ts | 9 +- .../model-gauntlet-results.json | 140 +-- .../model-gauntlet-results.md | 18 +- test/main/ipc/teams.test.ts | 29 + ...ProductionPromptArtifacts.safe-e2e.test.ts | 8 +- .../team/OpenCodePromptDeliveryLedger.test.ts | 30 + ...OpenCodePromptDeliveryRepairPolicy.test.ts | 19 + .../team/OpenCodeReadinessBridge.test.ts | 64 ++ ...OpenCodeRuntimeDeliveryDiagnostics.test.ts | 26 + .../team/OpenCodeTeamRuntimeAdapter.test.ts | 2 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 181 +++- .../services/team/TeamInboxWriter.test.ts | 141 +++ .../team/TeamMemberLogsFinder.test.ts | 146 +++ .../TeamMemberRuntimeAdvisoryService.test.ts | 4 + .../team/TeamProvisioningService.test.ts | 924 +++++++++++++++++- .../TeamTaskActivityIntervalService.test.ts | 533 +++++++++- .../main/services/team/TeamTaskWriter.test.ts | 67 +- .../stallMonitor/TeamTaskStallPolicy.test.ts | 32 +- .../services/team/teamTaskActiveState.test.ts | 32 + ...emberLogStreamSection.fixture-e2e.test.tsx | 92 ++ .../team/members/MemberLogsTab.test.ts | 65 ++ .../utils/memberActivityTimer.test.ts | 30 + test/renderer/utils/memberHelpers.test.ts | 17 + ...openCodeRuntimeDeliveryDiagnostics.test.ts | 21 + test/shared/utils/taskWorkDuration.test.ts | 33 + 66 files changed, 5085 insertions(+), 312 deletions(-) create mode 100644 src/features/member-log-stream/main/application/MemberRuntimeLogTailReader.ts create mode 100644 src/features/member-log-stream/main/application/__tests__/MemberRuntimeLogTailReader.test.ts create mode 100644 src/features/member-log-stream/renderer/ui/MemberRuntimeProcessLogsPanel.tsx create mode 100644 test/renderer/components/team/members/MemberLogsTab.test.ts diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index cf0a069c..74631333 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -75,6 +75,10 @@ function closeTimestampForInterval(interval, timestamp) { return timestamp; } +function isOpenReviewInterval(interval) { + return interval && interval.completedAt === undefined; +} + function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) { const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : ''; if (!reviewerName) return false; @@ -83,7 +87,7 @@ function openReviewInterval(task, reviewer, timestamp = new Date().toISOString() let changed = false; let hasOpenForReviewer = false; const nextIntervals = intervals.map((interval) => { - if (interval.completedAt) return interval; + if (!isOpenReviewInterval(interval)) return interval; if (normalizeActorKey(interval.reviewer) === reviewerKey) { hasOpenForReviewer = true; return interval; @@ -103,7 +107,7 @@ function closeReviewIntervals(task, timestamp = new Date().toISOString()) { if (!Array.isArray(task.reviewIntervals)) return false; let changed = false; task.reviewIntervals = task.reviewIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (!isOpenReviewInterval(interval)) return interval; changed = true; return { ...interval, completedAt: closeTimestampForInterval(interval, timestamp) }; }); diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index f4edc682..cc070872 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -201,13 +201,23 @@ function appendHistoryEvent(events, event) { return list; } +function isOpenReviewInterval(interval) { + return interval && interval.completedAt === undefined; +} + function closeOpenReviewIntervals(task, timestamp) { if (!Array.isArray(task.reviewIntervals)) return false; let changed = false; task.reviewIntervals = task.reviewIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (!isOpenReviewInterval(interval)) return interval; changed = true; - return { ...interval, completedAt: timestamp }; + const startedAtMs = Date.parse(interval.startedAt); + const timestampMs = Date.parse(timestamp); + const completedAt = + Number.isFinite(startedAtMs) && Number.isFinite(timestampMs) && timestampMs < startedAtMs + ? interval.startedAt + : timestamp; + return { ...interval, completedAt }; }); return changed; } @@ -466,7 +476,7 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) { const lastInterval = workIntervals.length > 0 ? workIntervals[workIntervals.length - 1] : null; if (task.status !== 'in_progress' && status === 'in_progress') { - if (!lastInterval || typeof lastInterval.completedAt === 'string') { + if (!lastInterval || lastInterval.completedAt !== undefined) { workIntervals.push({ startedAt: timestamp }); } } else if (task.status === 'in_progress' && status !== 'in_progress') { diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 01eb7170..11393b6c 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -722,6 +722,23 @@ describe('agent-teams-controller API', () => { expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']); }); + it('does not treat malformed empty completedAt work intervals as already open', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Malformed work interval' }); + const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`); + const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8')); + rawTask.workIntervals = [{ startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' }]; + fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2)); + + controller.tasks.startTask(task.id, 'bob'); + const reloaded = controller.tasks.getTask(task.id); + + expect(reloaded.workIntervals).toHaveLength(2); + expect(reloaded.workIntervals[0].completedAt).toBe(''); + expect(reloaded.workIntervals[1].completedAt).toBeUndefined(); + }); + it('tracks owner assignment history without duplicate same-owner events', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -851,6 +868,30 @@ describe('agent-teams-controller API', () => { expect(changed.reviewIntervals[0].completedAt).toBeTruthy(); }); + it('does not treat malformed empty completedAt review intervals as already open', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Review me', owner: 'bob' }); + + controller.tasks.completeTask(task.id, 'bob'); + controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' }); + + const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`); + const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8')); + rawTask.reviewIntervals = [ + { reviewer: 'alice', startedAt: '2026-01-01T00:00:00.000Z', completedAt: '' }, + ]; + fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2)); + + controller.review.startReview(task.id, { from: 'alice' }); + const reloaded = controller.tasks.getTask(task.id); + + expect(reloaded.reviewIntervals).toHaveLength(2); + expect(reloaded.reviewIntervals[0].completedAt).toBe(''); + expect(reloaded.reviewIntervals[1].reviewer).toBe('alice'); + expect(reloaded.reviewIntervals[1].completedAt).toBeUndefined(); + }); + it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -2212,13 +2253,22 @@ describe('agent-teams-controller API', () => { to: 'review', actor: 'carol', }); + rawTask.reviewIntervals = [{ reviewer: 'carol', startedAt: '2026-01-01T00:00:00.000Z' }]; fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2)); controller.review.startReview(task.id); - const startedEvents = controller.tasks - .getTask(task.id) - .historyEvents.filter((event) => event.type === 'review_started'); + const repairedTask = controller.tasks.getTask(task.id); + const startedEvents = repairedTask.historyEvents.filter( + (event) => event.type === 'review_started' + ); expect(startedEvents.at(-1).actor).toBe('alice'); + expect(repairedTask.reviewIntervals).toHaveLength(2); + expect(repairedTask.reviewIntervals[0]).toMatchObject({ + reviewer: 'carol', + completedAt: expect.any(String), + }); + expect(repairedTask.reviewIntervals[1].reviewer).toBe('alice'); + expect(repairedTask.reviewIntervals[1].completedAt).toBeUndefined(); const aliceBriefing = await controller.tasks.taskBriefing('alice'); const carolBriefing = await controller.tasks.taskBriefing('carol'); diff --git a/src/features/member-log-stream/contracts/api.ts b/src/features/member-log-stream/contracts/api.ts index 69283538..c467194d 100644 --- a/src/features/member-log-stream/contracts/api.ts +++ b/src/features/member-log-stream/contracts/api.ts @@ -3,6 +3,8 @@ import type { MemberLogPreviewResponse, MemberLogStreamRequestOptions, MemberLogStreamResponse, + MemberRuntimeLogTailOptions, + MemberRuntimeLogTailResponse, } from './dto'; export interface MemberLogStreamApi { @@ -16,5 +18,10 @@ export interface MemberLogStreamApi { memberNames: string[], options?: MemberLogPreviewRequestOptions ): Promise; + getMemberRuntimeLogTail( + teamName: string, + memberName: string, + options: MemberRuntimeLogTailOptions + ): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } diff --git a/src/features/member-log-stream/contracts/channels.ts b/src/features/member-log-stream/contracts/channels.ts index a0dc0115..d3bc56f3 100644 --- a/src/features/member-log-stream/contracts/channels.ts +++ b/src/features/member-log-stream/contracts/channels.ts @@ -1,3 +1,4 @@ export const MEMBER_LOG_STREAM_GET = 'member-log-stream:getMemberLogStream'; export const MEMBER_LOG_STREAM_GET_PREVIEWS = 'member-log-stream:getMemberLogPreviews'; export const MEMBER_LOG_STREAM_SET_TRACKING = 'member-log-stream:setTracking'; +export const MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL = 'member-log-stream:getMemberRuntimeLogTail'; diff --git a/src/features/member-log-stream/contracts/dto.ts b/src/features/member-log-stream/contracts/dto.ts index d0c7cfda..d247747b 100644 --- a/src/features/member-log-stream/contracts/dto.ts +++ b/src/features/member-log-stream/contracts/dto.ts @@ -110,3 +110,21 @@ export interface MemberLogPreviewResponse { members: MemberLogPreviewMember[]; generatedAt: string; } + +export type MemberRuntimeLogKind = 'stdout' | 'stderr' | 'events'; + +export interface MemberRuntimeLogTailOptions { + kind: MemberRuntimeLogKind; + maxBytes?: number; + forceRefresh?: boolean; +} + +export interface MemberRuntimeLogTailResponse { + kind: MemberRuntimeLogKind; + content: string; + truncated: boolean; + bytesRead: number; + fileSizeBytes?: number; + updatedAt?: string; + missing: boolean; +} diff --git a/src/features/member-log-stream/contracts/normalize.ts b/src/features/member-log-stream/contracts/normalize.ts index 7de2556b..b5f101e8 100644 --- a/src/features/member-log-stream/contracts/normalize.ts +++ b/src/features/member-log-stream/contracts/normalize.ts @@ -2,6 +2,8 @@ import type { MemberLogPreviewMember, MemberLogPreviewResponse, MemberLogStreamResponse, + MemberRuntimeLogKind, + MemberRuntimeLogTailResponse, } from './dto'; export function createEmptyMemberLogStreamResponse( @@ -91,3 +93,52 @@ export function normalizeMemberLogPreviewResponse( : new Date().toISOString(), }; } + +const MEMBER_RUNTIME_LOG_KINDS = new Set(['stdout', 'stderr', 'events']); + +function normalizeMemberRuntimeLogKind(kind: unknown): MemberRuntimeLogKind { + return MEMBER_RUNTIME_LOG_KINDS.has(kind as MemberRuntimeLogKind) + ? (kind as MemberRuntimeLogKind) + : 'stdout'; +} + +function normalizeOptionalFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +export function createEmptyMemberRuntimeLogTailResponse( + kind: MemberRuntimeLogKind = 'stdout' +): MemberRuntimeLogTailResponse { + return { + kind, + content: '', + truncated: false, + bytesRead: 0, + missing: true, + }; +} + +export function normalizeMemberRuntimeLogTailResponse( + response: MemberRuntimeLogTailResponse | null | undefined +): MemberRuntimeLogTailResponse { + if (!response) { + return createEmptyMemberRuntimeLogTailResponse(); + } + + const kind = normalizeMemberRuntimeLogKind(response.kind); + const fileSizeBytes = normalizeOptionalFiniteNumber(response.fileSizeBytes); + const updatedAt = + typeof response.updatedAt === 'string' && response.updatedAt.length > 0 + ? response.updatedAt + : undefined; + + return { + kind, + content: typeof response.content === 'string' ? response.content : '', + truncated: response.truncated === true, + bytesRead: normalizeOptionalFiniteNumber(response.bytesRead) ?? 0, + ...(fileSizeBytes !== undefined ? { fileSizeBytes } : {}), + ...(updatedAt !== undefined ? { updatedAt } : {}), + missing: response.missing === true, + }; +} diff --git a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts index 8f1569fa..ee267426 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/__tests__/registerMemberLogStreamIpc.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, MEMBER_LOG_STREAM_SET_TRACKING, } from '../../../../../contracts'; import { @@ -50,6 +51,16 @@ function emptyPreviewResponse(): MemberLogPreviewResponse { }; } +function emptyRuntimeLogTailResponse() { + return { + kind: 'stdout' as const, + content: '', + truncated: false, + bytesRead: 0, + missing: true, + }; +} + function createFakeIpcMain(): { handlers: Map unknown>; ipcMain: { @@ -78,6 +89,7 @@ describe('registerMemberLogStreamIpc', () => { const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -111,6 +123,7 @@ describe('registerMemberLogStreamIpc', () => { const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -138,6 +151,7 @@ describe('registerMemberLogStreamIpc', () => { const feature: MemberLogStreamFeatureFacade = { getMemberLogStream, getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -187,6 +201,7 @@ describe('registerMemberLogStreamIpc', () => { const feature: MemberLogStreamFeatureFacade = { getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking, }; @@ -206,6 +221,7 @@ describe('registerMemberLogStreamIpc', () => { expect(handlers.has(MEMBER_LOG_STREAM_GET)).toBe(false); expect(handlers.has(MEMBER_LOG_STREAM_GET_PREVIEWS)).toBe(false); + expect(handlers.has(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL)).toBe(false); expect(handlers.has(MEMBER_LOG_STREAM_SET_TRACKING)).toBe(false); }); @@ -215,6 +231,7 @@ describe('registerMemberLogStreamIpc', () => { const feature: MemberLogStreamFeatureFacade = { getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), getMemberLogPreviews, + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking: vi.fn(), }; @@ -243,12 +260,66 @@ describe('registerMemberLogStreamIpc', () => { }); }); + it('validates runtime log tail requests before calling the feature facade', async () => { + const { handlers, ipcMain } = createFakeIpcMain(); + const getMemberRuntimeLogTail = vi.fn().mockResolvedValue({ + kind: 'stderr', + content: 'runtime error', + truncated: false, + bytesRead: 13, + missing: false, + }); + const feature: MemberLogStreamFeatureFacade = { + getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), + getMemberLogPreviews: vi.fn().mockResolvedValue(emptyPreviewResponse()), + getMemberRuntimeLogTail, + setMemberLogStreamTracking: vi.fn(), + }; + + registerMemberLogStreamIpc(ipcMain as never, feature); + const getRuntimeTail = handlers.get(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL)!; + + await expect( + getRuntimeTail({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { + kind: 'stderr', + maxBytes: 999999, + forceRefresh: true, + }) + ).resolves.toEqual({ + success: true, + data: { + kind: 'stderr', + content: 'runtime error', + truncated: false, + bytesRead: 13, + missing: false, + }, + }); + expect(getMemberRuntimeLogTail).toHaveBeenCalledWith({ + teamName: 'alpha-team', + memberName: 'alice', + options: { + kind: 'stderr', + maxBytes: 512 * 1024, + forceRefresh: true, + }, + }); + + await expect( + getRuntimeTail({} as IpcMainInvokeEvent, 'alpha-team', 'alice', { kind: 'bad' }) + ).resolves.toEqual({ + success: false, + error: 'kind must be stdout, stderr, or events', + }); + }); + it('rejects unknown batch preview options and unsafe lane maps', async () => { const { handlers, ipcMain } = createFakeIpcMain(); const getMemberLogPreviews = vi.fn().mockResolvedValue(emptyPreviewResponse()); const feature: MemberLogStreamFeatureFacade = { getMemberLogStream: vi.fn().mockResolvedValue(emptyResponse()), getMemberLogPreviews, + getMemberRuntimeLogTail: vi.fn().mockResolvedValue(emptyRuntimeLogTailResponse()), setMemberLogStreamTracking: vi.fn(), }; diff --git a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts index ddc18b93..eb48f340 100644 --- a/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts +++ b/src/features/member-log-stream/main/adapters/input/ipc/registerMemberLogStreamIpc.ts @@ -4,9 +4,11 @@ import { createLogger } from '@shared/utils/logger'; import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, MEMBER_LOG_STREAM_SET_TRACKING, normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, + normalizeMemberRuntimeLogTailResponse, } from '../../../../contracts'; import type { @@ -14,6 +16,8 @@ import type { MemberLogPreviewResponse, MemberLogStreamRequestOptions, MemberLogStreamResponse, + MemberRuntimeLogTailOptions, + MemberRuntimeLogTailResponse, } from '../../../../contracts'; import type { MemberLogStreamFeatureFacade } from '../../../composition/createMemberLogStreamFeature'; import type { IpcResult } from '@shared/types'; @@ -27,6 +31,8 @@ const ALLOWED_PREVIEW_OPTION_KEYS = new Set([ 'laneIdsByMember', 'forceRefresh', ]); +const ALLOWED_RUNTIME_LOG_OPTION_KEYS = new Set(['kind', 'maxBytes', 'forceRefresh']); +const MEMBER_RUNTIME_LOG_KINDS = new Set(['stdout', 'stderr', 'events']); interface ValidationResult { valid: boolean; @@ -217,6 +223,50 @@ function normalizePreviewOptions(options: unknown): ValidationResult<{ }; } +function normalizeRuntimeLogOptions( + options: unknown +): ValidationResult { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + return { valid: false, error: 'options must be an object' }; + } + + const record = options as Record; + for (const key of Object.keys(record)) { + if (!ALLOWED_RUNTIME_LOG_OPTION_KEYS.has(key)) { + return { valid: false, error: `Unknown getMemberRuntimeLogTail option: ${key}` }; + } + } + + if (!MEMBER_RUNTIME_LOG_KINDS.has(record.kind as string)) { + return { valid: false, error: 'kind must be stdout, stderr, or events' }; + } + + let maxBytes: number | undefined; + if (record.maxBytes != null) { + if (typeof record.maxBytes !== 'number' || !Number.isFinite(record.maxBytes)) { + return { valid: false, error: 'maxBytes must be a finite number' }; + } + maxBytes = Math.max(1024, Math.min(512 * 1024, Math.floor(record.maxBytes))); + } + + let forceRefresh: boolean | undefined; + if (record.forceRefresh != null) { + if (typeof record.forceRefresh !== 'boolean') { + return { valid: false, error: 'forceRefresh must be a boolean' }; + } + forceRefresh = record.forceRefresh; + } + + return { + valid: true, + value: { + kind: record.kind as MemberRuntimeLogTailOptions['kind'], + ...(maxBytes !== undefined ? { maxBytes } : {}), + ...(forceRefresh !== undefined ? { forceRefresh } : {}), + }, + }; +} + export function registerMemberLogStreamIpc( ipcMain: IpcMain, feature: MemberLogStreamFeatureFacade @@ -324,10 +374,49 @@ export function registerMemberLogStreamIpc( } } ); + + ipcMain.handle( + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, + async ( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown, + options?: MemberRuntimeLogTailOptions + ): Promise> => { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vMember = validateMemberName(memberName); + if (!vMember.valid) { + return { success: false, error: vMember.error ?? 'Invalid memberName' }; + } + const vOptions = normalizeRuntimeLogOptions(options); + if (!vOptions.valid) { + return { success: false, error: vOptions.error ?? 'Invalid options' }; + } + + try { + const response = await feature.getMemberRuntimeLogTail({ + teamName: vTeam.value!, + memberName: vMember.value!, + options: vOptions.value!, + }); + return { success: true, data: normalizeMemberRuntimeLogTailResponse(response) }; + } catch (error) { + logger.error('Failed to load member runtime log tail', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to load member runtime log tail', + }; + } + } + ); } export function removeMemberLogStreamIpc(ipcMain: IpcMain): void { ipcMain.removeHandler(MEMBER_LOG_STREAM_GET); ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_PREVIEWS); + ipcMain.removeHandler(MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL); ipcMain.removeHandler(MEMBER_LOG_STREAM_SET_TRACKING); } diff --git a/src/features/member-log-stream/main/application/MemberRuntimeLogTailReader.ts b/src/features/member-log-stream/main/application/MemberRuntimeLogTailReader.ts new file mode 100644 index 00000000..5d990f44 --- /dev/null +++ b/src/features/member-log-stream/main/application/MemberRuntimeLogTailReader.ts @@ -0,0 +1,176 @@ +/* eslint-disable security/detect-non-literal-fs-filename -- Runtime log paths are derived from validated team/member names under the configured teams base path. */ +import { promises as fs } from 'fs'; +import path from 'path'; + +import { getTeamsBasePath } from '@main/utils/pathDecoder'; + +import type { MemberRuntimeLogKind, MemberRuntimeLogTailResponse } from '../../contracts'; + +const DEFAULT_RUNTIME_LOG_TAIL_BYTES = 128 * 1024; +const MAX_RUNTIME_LOG_TAIL_BYTES = 512 * 1024; +const MIN_RUNTIME_LOG_TAIL_BYTES = 1024; + +const RUNTIME_LOG_FILES: Record = { + stdout: 'stdout.log', + stderr: 'stderr.log', + events: 'runtime.jsonl', +}; +const WINDOWS_RESERVED_BASENAMES = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +export interface GetMemberRuntimeLogTailInput { + teamName: string; + memberName: string; + kind: MemberRuntimeLogKind; + maxBytes?: number; +} + +export interface MemberRuntimeLogTailReaderOptions { + teamsBasePath?: string; +} + +function sanitizeRuntimeLogSegment(value: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + const normalized = sanitized + .trim() + .replace(/[. ]+$/g, '') + .toLowerCase(); + const stem = normalized.split('.')[0] ?? normalized; + return WINDOWS_RESERVED_BASENAMES.has(stem) ? `_${sanitized}` : sanitized; +} + +function clampMaxBytes(maxBytes: number | undefined): number { + if (!Number.isFinite(maxBytes ?? NaN)) return DEFAULT_RUNTIME_LOG_TAIL_BYTES; + return Math.max( + MIN_RUNTIME_LOG_TAIL_BYTES, + Math.min(MAX_RUNTIME_LOG_TAIL_BYTES, Math.floor(maxBytes as number)) + ); +} + +function isPathInside(parentPath: string, childPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function redactRuntimeLogSecrets(content: string): string { + let redacted = content; + + redacted = redacted.replace(/\b(Authorization\s*:\s*Bearer)\s+([^\s"',;]+)/gi, '$1 [redacted]'); + redacted = redacted.replace(/\b(Bearer)\s+([A-Za-z0-9._~+/=-]{20,})/gi, '$1 [redacted]'); + redacted = redacted.replace( + /\b((?:OPENAI|ANTHROPIC|CODEX|GEMINI|GOOGLE|OPENROUTER|CLAUDE)[A-Z0-9_]*_(?:API_)?KEY)\s*=\s*("[^"]+"|'[^']+'|[^\s"',;]+)/gi, + '$1=[redacted]' + ); + redacted = redacted.replace( + /(--(?:api-key|token|auth-token|authorization|secret|password)(?:=|\s+))("[^"]+"|'[^']+'|[^\s"',;]+)/gi, + '$1[redacted]' + ); + redacted = redacted.replace( + /\b(sk-ant-[A-Za-z0-9_-]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g, + '[redacted]' + ); + + return redacted; +} + +export class MemberRuntimeLogTailReader { + private readonly teamsBasePath: string; + + constructor(options: MemberRuntimeLogTailReaderOptions = {}) { + this.teamsBasePath = options.teamsBasePath ?? getTeamsBasePath(); + } + + async getTail(input: GetMemberRuntimeLogTailInput): Promise { + const maxBytes = clampMaxBytes(input.maxBytes); + const runtimeDir = path.resolve( + this.teamsBasePath, + sanitizeRuntimeLogSegment(input.teamName), + 'runtime' + ); + const filePath = path.resolve( + runtimeDir, + `${sanitizeRuntimeLogSegment(input.memberName)}.${RUNTIME_LOG_FILES[input.kind]}` + ); + + if (!isPathInside(runtimeDir, filePath)) { + throw new Error('Invalid member runtime log path'); + } + + let stat; + try { + stat = await fs.stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { + kind: input.kind, + content: '', + truncated: false, + bytesRead: 0, + missing: true, + }; + } + throw error; + } + + if (!stat.isFile()) { + return { + kind: input.kind, + content: '', + truncated: false, + bytesRead: 0, + fileSizeBytes: stat.size, + updatedAt: stat.mtime.toISOString(), + missing: true, + }; + } + + const bytesToRead = Math.min(stat.size, maxBytes); + const start = Math.max(0, stat.size - bytesToRead); + const buffer = Buffer.alloc(bytesToRead); + let actualBytesRead = 0; + + if (bytesToRead > 0) { + const handle = await fs.open(filePath, 'r'); + try { + const result = await handle.read(buffer, 0, bytesToRead, start); + actualBytesRead = result.bytesRead; + } finally { + await handle.close(); + } + } + const contentBuffer = + actualBytesRead === bytesToRead ? buffer : buffer.subarray(0, actualBytesRead); + + return { + kind: input.kind, + content: redactRuntimeLogSecrets(contentBuffer.toString('utf8')), + truncated: stat.size > bytesToRead, + bytesRead: actualBytesRead, + fileSizeBytes: stat.size, + updatedAt: stat.mtime.toISOString(), + missing: false, + }; + } +} diff --git a/src/features/member-log-stream/main/application/__tests__/MemberRuntimeLogTailReader.test.ts b/src/features/member-log-stream/main/application/__tests__/MemberRuntimeLogTailReader.test.ts new file mode 100644 index 00000000..9b859dd5 --- /dev/null +++ b/src/features/member-log-stream/main/application/__tests__/MemberRuntimeLogTailReader.test.ts @@ -0,0 +1,104 @@ +/* eslint-disable security/detect-non-literal-fs-filename -- Tests write isolated temp runtime log fixtures. */ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { MemberRuntimeLogTailReader } from '../MemberRuntimeLogTailReader'; + +const tempDirs: string[] = []; + +async function createTempTeamsBase(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'member-runtime-log-tail-')); + tempDirs.push(dir); + return dir; +} + +async function writeRuntimeLog( + teamsBasePath: string, + teamName: string, + memberName: string, + suffix: string, + content: string +): Promise { + const runtimeDir = path.join(teamsBasePath, teamName, 'runtime'); + await mkdir(runtimeDir, { recursive: true }); + await writeFile(path.join(runtimeDir, `${memberName}.${suffix}`), content, 'utf8'); +} + +describe('MemberRuntimeLogTailReader', () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + }); + + it('reads only the bounded tail of large process logs', async () => { + const teamsBasePath = await createTempTeamsBase(); + const reader = new MemberRuntimeLogTailReader({ teamsBasePath }); + await writeRuntimeLog( + teamsBasePath, + 'alpha-team', + 'alice', + 'stdout.log', + `${'x'.repeat(4096)}\nvisible tail` + ); + + const result = await reader.getTail({ + teamName: 'alpha-team', + memberName: 'alice', + kind: 'stdout', + maxBytes: 1024, + }); + + expect(result.missing).toBe(false); + expect(result.truncated).toBe(true); + expect(result.bytesRead).toBe(1024); + expect(result.content).toContain('visible tail'); + }); + + it('returns missing without throwing when the runtime log file does not exist', async () => { + const teamsBasePath = await createTempTeamsBase(); + const reader = new MemberRuntimeLogTailReader({ teamsBasePath }); + + await expect( + reader.getTail({ + teamName: 'alpha-team', + memberName: 'alice', + kind: 'stderr', + }) + ).resolves.toMatchObject({ + kind: 'stderr', + missing: true, + content: '', + bytesRead: 0, + }); + }); + + it('redacts obvious secrets before returning process log content', async () => { + const teamsBasePath = await createTempTeamsBase(); + const reader = new MemberRuntimeLogTailReader({ teamsBasePath }); + await writeRuntimeLog( + teamsBasePath, + 'alpha-team', + 'alice', + 'stderr.log', + [ + 'Authorization: Bearer secret-token-value-1234567890', + 'OPENAI_API_KEY=sk-secret-key-value-1234567890', + '--api-key sk-ant-secret-value-1234567890', + ].join('\n') + ); + + const result = await reader.getTail({ + teamName: 'alpha-team', + memberName: 'alice', + kind: 'stderr', + }); + + expect(result.content).toContain('Authorization: Bearer [redacted]'); + expect(result.content).toContain('OPENAI_API_KEY=[redacted]'); + expect(result.content).not.toContain('secret-token-value'); + expect(result.content).not.toContain('sk-secret-key-value'); + expect(result.content).not.toContain('sk-ant-secret-value'); + }); +}); diff --git a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts index 6ad0cfb1..a4b61305 100644 --- a/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts +++ b/src/features/member-log-stream/main/composition/createMemberLogStreamFeature.ts @@ -5,7 +5,9 @@ import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { createEmptyMemberLogPreviewResponse, createEmptyMemberLogStreamResponse, + createEmptyMemberRuntimeLogTailResponse, } from '../../contracts'; +import { MemberRuntimeLogTailReader } from '../application/MemberRuntimeLogTailReader'; import { GetMemberLogPreviewsUseCase } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; import { GetMemberLogStreamUseCase } from '../../core/application/use-cases/GetMemberLogStreamUseCase'; import { SetMemberLogStreamTrackingUseCase } from '../../core/application/use-cases/SetMemberLogStreamTrackingUseCase'; @@ -17,7 +19,12 @@ import { OpenCodeMemberRuntimePreviewSource } from '../adapters/output/sources/O import { OpenCodeMemberRuntimeStreamSource } from '../adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; import { isMemberLogStreamReadEnabled } from '../featureGates'; -import type { MemberLogPreviewResponse, MemberLogStreamResponse } from '../../contracts'; +import type { + MemberLogPreviewResponse, + MemberLogStreamResponse, + MemberRuntimeLogTailOptions, + MemberRuntimeLogTailResponse, +} from '../../contracts'; import type { LoggerPort } from '../../core/application/ports/LoggerPort'; import type { MemberLogStreamTrackingPort } from '../../core/application/ports/MemberLogStreamTrackingPort'; import type { GetMemberLogPreviewsInput } from '../../core/application/use-cases/GetMemberLogPreviewsUseCase'; @@ -29,6 +36,11 @@ import type { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFin export interface MemberLogStreamFeatureFacade { getMemberLogStream(input: GetMemberLogStreamInput): Promise; getMemberLogPreviews(input: GetMemberLogPreviewsInput): Promise; + getMemberRuntimeLogTail(input: { + teamName: string; + memberName: string; + options: MemberRuntimeLogTailOptions; + }): Promise; setMemberLogStreamTracking(teamName: string, enabled: boolean): Promise; } @@ -49,11 +61,13 @@ export function createMemberLogStreamFeature(deps: { logSourceTracker: TeamLogSourceTracker; runtimeBridge: ClaudeMultimodelBridgeService; configReader?: TeamConfigReader; + runtimeLogTailReader?: MemberRuntimeLogTailReader; logger: LoggerPort; }): MemberLogStreamFeatureFacade { const chunkBuilder = new BoardTaskExactLogChunkBuilder(); const strictParser = new BoardTaskExactLogStrictParser(); const configReader = deps.configReader ?? new TeamConfigReader(); + const runtimeLogTailReader = deps.runtimeLogTailReader ?? new MemberRuntimeLogTailReader(); const sources = [ new ClaudeMemberTranscriptStreamSource( deps.logsFinder, @@ -96,6 +110,17 @@ export function createMemberLogStreamFeature(deps: { } return getPreviewsUseCase.execute(input); }, + getMemberRuntimeLogTail: async (input) => { + if (!isMemberLogStreamReadEnabled()) { + return createEmptyMemberRuntimeLogTailResponse(input.options.kind); + } + return runtimeLogTailReader.getTail({ + teamName: input.teamName, + memberName: input.memberName, + kind: input.options.kind, + maxBytes: input.options.maxBytes, + }); + }, setMemberLogStreamTracking: (teamName, enabled) => trackingUseCase.execute(teamName, enabled), }; } diff --git a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts index 50a7e489..51764de6 100644 --- a/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts +++ b/src/features/member-log-stream/preload/__tests__/createMemberLogStreamBridge.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, MEMBER_LOG_STREAM_SET_TRACKING, } from '../../contracts'; import { createMemberLogStreamBridge } from '../createMemberLogStreamBridge'; @@ -122,4 +123,46 @@ describe('createMemberLogStreamBridge', () => { } ); }); + + it('forwards process runtime log tail IPC requests and normalizes response payloads', async () => { + mocks.ipcRenderer.invoke.mockResolvedValueOnce({ + success: true, + data: { + kind: 'stderr', + content: 'OpenCode API error', + truncated: true, + bytesRead: 131072, + fileSizeBytes: 262144, + updatedAt: '2026-04-02T00:00:00.000Z', + missing: false, + }, + }); + const bridge = createMemberLogStreamBridge(); + + const response = await bridge.getMemberRuntimeLogTail('alpha-team', 'alice', { + kind: 'stderr', + maxBytes: 131072, + forceRefresh: true, + }); + + expect(response).toEqual({ + kind: 'stderr', + content: 'OpenCode API error', + truncated: true, + bytesRead: 131072, + fileSizeBytes: 262144, + updatedAt: '2026-04-02T00:00:00.000Z', + missing: false, + }); + expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith( + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, + 'alpha-team', + 'alice', + { + kind: 'stderr', + maxBytes: 131072, + forceRefresh: true, + } + ); + }); }); diff --git a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts index 2c988513..fd40fee3 100644 --- a/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts +++ b/src/features/member-log-stream/preload/createMemberLogStreamBridge.ts @@ -3,9 +3,11 @@ import { ipcRenderer } from 'electron'; import { MEMBER_LOG_STREAM_GET, MEMBER_LOG_STREAM_GET_PREVIEWS, + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, MEMBER_LOG_STREAM_SET_TRACKING, normalizeMemberLogPreviewResponse, normalizeMemberLogStreamResponse, + normalizeMemberRuntimeLogTailResponse, } from '../contracts'; import type { @@ -14,6 +16,8 @@ import type { MemberLogStreamApi, MemberLogStreamRequestOptions, MemberLogStreamResponse, + MemberRuntimeLogTailOptions, + MemberRuntimeLogTailResponse, } from '../contracts'; import type { IpcResult } from '@shared/types'; @@ -53,6 +57,19 @@ export function createMemberLogStreamBridge(): MemberLogStreamApi { options ) ), + getMemberRuntimeLogTail: async ( + teamName: string, + memberName: string, + options: MemberRuntimeLogTailOptions + ): Promise => + normalizeMemberRuntimeLogTailResponse( + await invokeIpcWithResult( + MEMBER_LOG_STREAM_GET_RUNTIME_LOG_TAIL, + teamName, + memberName, + options + ) + ), setMemberLogStreamTracking: (teamName: string, enabled: boolean): Promise => invokeIpcWithResult(MEMBER_LOG_STREAM_SET_TRACKING, teamName, enabled), }; diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx index fe3aed25..16460f31 100644 --- a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx +++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useStore } from '@renderer/store'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { useMemberLogStream } from '../hooks/useMemberLogStream'; import { ExecutionLogStreamView } from '../ui/ExecutionLogStreamView'; +import { MemberRuntimeProcessLogsPanel } from '../ui/MemberRuntimeProcessLogsPanel'; import type { MemberLogStreamSegment } from '../../contracts'; import type { ResolvedTeamMember } from '@shared/types'; @@ -41,6 +42,7 @@ export function MemberLogStreamSection({ enabled = true, onInitialLoadErrorChange, }: Readonly): React.JSX.Element { + const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution'); const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName)); const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled }); const hasInitialLoadError = Boolean(error && !stream && !loading); @@ -57,22 +59,57 @@ export function MemberLogStreamSection({ }, [hasInitialLoadError, onInitialLoadErrorChange]); return ( - +
+
+ + +
+ + {selectedLogView === 'execution' ? ( + + ) : ( + + )} +
); } diff --git a/src/features/member-log-stream/renderer/ui/MemberRuntimeProcessLogsPanel.tsx b/src/features/member-log-stream/renderer/ui/MemberRuntimeProcessLogsPanel.tsx new file mode 100644 index 00000000..e80aa575 --- /dev/null +++ b/src/features/member-log-stream/renderer/ui/MemberRuntimeProcessLogsPanel.tsx @@ -0,0 +1,278 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Check, Clipboard, Loader2, RefreshCw } from 'lucide-react'; + +import { + createEmptyMemberRuntimeLogTailResponse, + normalizeMemberRuntimeLogTailResponse, + type MemberRuntimeLogKind, + type MemberRuntimeLogTailResponse, +} from '../../contracts'; + +const PROCESS_LOG_KINDS: MemberRuntimeLogKind[] = ['stdout', 'stderr', 'events']; +const PROCESS_LOG_AUTO_REFRESH_MS = 4000; +const PROCESS_LOG_TAIL_BYTES = 128 * 1024; + +function formatBytes(bytes: number | undefined): string { + if (!Number.isFinite(bytes ?? NaN)) return '--'; + const safeBytes = Math.max(0, bytes ?? 0); + if (safeBytes < 1024) return `${safeBytes} B`; + const kb = safeBytes / 1024; + if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`; +} + +function buildStatusText(log: MemberRuntimeLogTailResponse | null): string | null { + if (!log) return null; + if (log.missing) return 'No process log file captured for this member yet.'; + if (!log.content) return 'Process log file is empty.'; + if (log.truncated) return `Showing last ${formatBytes(log.bytesRead)}.`; + return `Showing ${formatBytes(log.bytesRead)}.`; +} + +function ProcessLogKindTabs({ + selected, + onSelect, +}: { + selected: MemberRuntimeLogKind; + onSelect: (kind: MemberRuntimeLogKind) => void; +}): React.JSX.Element { + return ( +
+ {PROCESS_LOG_KINDS.map((kind) => ( + + ))} +
+ ); +} + +function ProcessLogVirtualList({ + content, + wrapLines, +}: { + content: string; + wrapLines: boolean; +}): React.JSX.Element { + const parentRef = useRef(null); + const lines = useMemo(() => content.split(/\r?\n/), [content]); + const rowVirtualizer = useVirtualizer({ + count: lines.length, + getScrollElement: () => parentRef.current, + estimateSize: () => (wrapLines ? 36 : 20), + overscan: 20, + }); + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( +
+ + {virtualRow.index + 1} + + + {lines[virtualRow.index] || ' '} + +
+ ))} +
+
+ ); +} + +export function MemberRuntimeProcessLogsPanel({ + teamName, + memberName, + enabled, +}: { + teamName: string; + memberName: string; + enabled: boolean; +}): React.JSX.Element { + const [kind, setKind] = useState('stdout'); + const [log, setLog] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [autoRefresh, setAutoRefresh] = useState(false); + const [wrapLines, setWrapLines] = useState(false); + const [copied, setCopied] = useState(false); + const requestSeqRef = useRef(0); + const copiedTimerRef = useRef | null>(null); + + const loadLog = useCallback( + async (options?: { background?: boolean; forceRefresh?: boolean }) => { + if (!enabled) return; + const requestSeq = requestSeqRef.current + 1; + requestSeqRef.current = requestSeq; + if (!options?.background) { + setLoading(true); + setError(null); + } + + try { + const response = normalizeMemberRuntimeLogTailResponse( + await api.memberLogStream.getMemberRuntimeLogTail(teamName, memberName, { + kind, + maxBytes: PROCESS_LOG_TAIL_BYTES, + ...(options?.forceRefresh ? { forceRefresh: true } : {}), + }) + ); + if (requestSeqRef.current !== requestSeq) return; + setLog(response); + setError(null); + } catch (loadError) { + if (requestSeqRef.current !== requestSeq) return; + if (!options?.background) { + setLog(createEmptyMemberRuntimeLogTailResponse(kind)); + } + setError(loadError instanceof Error ? loadError.message : 'Failed to load process logs'); + } finally { + if (requestSeqRef.current === requestSeq) { + setLoading(false); + } + } + }, + [enabled, kind, memberName, teamName] + ); + + useEffect(() => { + requestSeqRef.current += 1; + setLog(null); + setError(null); + if (enabled) { + void loadLog({ forceRefresh: true }); + } + }, [enabled, kind, loadLog]); + + useEffect(() => { + if (!enabled || !autoRefresh) return undefined; + const interval = setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + void loadLog({ background: true, forceRefresh: true }); + }, PROCESS_LOG_AUTO_REFRESH_MS); + return () => clearInterval(interval); + }, [autoRefresh, enabled, loadLog]); + + useEffect(() => { + return () => { + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + }; + }, []); + + const copyCurrentLog = useCallback(async () => { + const content = log?.content ?? ''; + if (!content) return; + try { + await navigator.clipboard.writeText(content); + setCopied(true); + if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = setTimeout(() => setCopied(false), 1600); + } catch (copyError) { + setError(copyError instanceof Error ? copyError.message : 'Failed to copy process logs'); + } + }, [log?.content]); + + const statusText = buildStatusText(log); + const hasContent = Boolean(log?.content); + + return ( +
+
+
+ + + {kind} + +
+ +
+ + + + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {statusText ? ( +
{statusText}
+ ) : null} + + {loading && !log ? ( +
+ + Loading process log tail... +
+ ) : hasContent ? ( + + ) : ( +
+ {statusText ?? 'No process log file captured for this member yet.'} +
+ )} +
+ ); +} diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 2eb9f01d..2a92a919 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -523,6 +523,9 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) }); } + await services.teamProvisioningService?.repairStaleTaskActivityIntervalsBeforeSnapshot?.( + teamName + ); return reply.send(await getTeamDataService(services).getTeamData(teamName)); } catch (error) { if (shouldLogError(error)) { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 31831d5b..d88fb17e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1044,6 +1044,8 @@ async function handleGetData( return { success: false, error: 'TEAM_DRAFT' }; } + await getTeamProvisioningService().repairStaleTaskActivityIntervalsBeforeSnapshot?.(tn); + if (workerAvailable) { try { data = diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts index 0606657c..077168b9 100644 --- a/src/main/services/team/TaskChangeComputer.ts +++ b/src/main/services/team/TaskChangeComputer.ts @@ -179,7 +179,12 @@ export class TaskChangeComputer { if (!Number.isFinite(startMs)) continue; const endMsRaw = typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; - const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + const endMs = + interval.completedAt === undefined + ? null + : Number.isFinite(endMsRaw) + ? Math.max(endMsRaw, startMs) + : startMs; normalized.push({ startMs, endMs, @@ -192,7 +197,13 @@ export class TaskChangeComputer { const startTimestamp = normalized[0]?.startedAt ?? ''; const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>( (acc, item) => { - if (item.endMs == null || typeof item.completedAt !== 'string') return acc; + if ( + item.endMs == null || + typeof item.completedAt !== 'string' || + !Number.isFinite(Date.parse(item.completedAt)) + ) { + return acc; + } if (!acc || item.endMs > acc.endMs) { return { endMs: item.endMs, endTimestamp: item.completedAt }; } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 366ef653..8f3c33d8 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -7,7 +7,35 @@ import { atomicWriteAsync } from './atomicWrite'; import { withFileLock } from './fileLock'; import { withInboxLock } from './inboxLock'; -import type { InboxMessage, SendMessageRequest, SendMessageResult } from '@shared/types'; +import type { InboxMessage, SendMessageRequest, SendMessageResult, TaskRef } from '@shared/types'; + +export interface MergeRuntimeDeliveryTaskRefsRequest { + inboxName: string; + messageId: string; + relayOfMessageId: string; + from: string; + taskRefs: TaskRef[]; +} + +export interface MergeRuntimeDeliveryTaskRefsResult { + found: boolean; + updated: boolean; + message?: InboxMessage & { messageId: string }; +} + +export interface CorrelateRuntimeDeliveryReplyRequest { + inboxName: string; + messageId: string; + relayOfMessageId: string; + from: string; + taskRefs?: TaskRef[]; +} + +export interface CorrelateRuntimeDeliveryReplyResult { + found: boolean; + updated: boolean; + message?: InboxMessage & { messageId: string }; +} export class TeamInboxWriter { async sendMessage(teamName: string, request: SendMessageRequest): Promise { @@ -54,10 +82,34 @@ export class TeamInboxWriter { await withInboxLock(inboxPath, async () => { for (let attempt = 0; attempt < 3; attempt++) { const list = await this.readInbox(inboxPath); - const duplicate = this.findRuntimeDeliveryDuplicate(list, payload); - if (duplicate) { + const duplicateIndex = this.findRuntimeDeliveryDuplicateIndex(list, payload); + if (duplicateIndex >= 0) { + const duplicate = list[duplicateIndex]; + const merged = this.mergeTaskRefs(duplicate.taskRefs, payload.taskRefs); resultMessageId = duplicate.messageId ?? messageId; resultDeduplicated = true; + if (merged.changed) { + list[duplicateIndex] = { + ...duplicate, + taskRefs: merged.taskRefs, + }; + await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2)); + const written = await this.readInbox(inboxPath); + const writtenDuplicateIndex = this.findRuntimeDeliveryDuplicateIndex( + written, + payload + ); + const writtenDuplicate = + writtenDuplicateIndex >= 0 ? written[writtenDuplicateIndex] : null; + if ( + writtenDuplicate && + this.taskRefsIncludeAll(writtenDuplicate.taskRefs, payload.taskRefs ?? []) + ) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + continue; + } return; } list.push(payload); @@ -79,16 +131,188 @@ export class TeamInboxWriter { }; } - private findRuntimeDeliveryDuplicate( + async mergeRuntimeDeliveryTaskRefs( + teamName: string, + request: MergeRuntimeDeliveryTaskRefsRequest + ): Promise { + const inboxName = request.inboxName.trim(); + const messageId = request.messageId.trim(); + const relayOfMessageId = request.relayOfMessageId.trim(); + const taskRefs = this.normalizeTaskRefs(request.taskRefs); + if (!inboxName || !messageId || !relayOfMessageId || taskRefs.length === 0) { + return { found: false, updated: false }; + } + + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`); + const expectedFrom = this.normalizeComparableParticipant(request.from); + if (!expectedFrom) { + return { found: false, updated: false }; + } + + let result: MergeRuntimeDeliveryTaskRefsResult = { found: false, updated: false }; + await withFileLock(inboxPath, async () => { + await withInboxLock(inboxPath, async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const list = await this.readInbox(inboxPath); + const index = list.findIndex((message) => { + const rowMessageId = + typeof message.messageId === 'string' ? message.messageId.trim() : ''; + const rowRelayOf = + typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : ''; + const rowSource = message.source; + return ( + rowMessageId === messageId && + rowRelayOf === relayOfMessageId && + this.normalizeComparableParticipant(message.from) === expectedFrom && + (rowSource === undefined || rowSource === 'runtime_delivery') + ); + }); + if (index < 0) { + result = { found: false, updated: false }; + return; + } + + const existing = list[index]; + const merged = this.mergeTaskRefs(existing.taskRefs, taskRefs); + if (!merged.changed) { + result = { + found: true, + updated: false, + message: { ...existing, messageId }, + }; + return; + } + + list[index] = { ...existing, taskRefs: merged.taskRefs }; + await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2)); + const written = await this.readInbox(inboxPath); + const verified = written.find((message) => { + const rowMessageId = + typeof message.messageId === 'string' ? message.messageId.trim() : ''; + const rowRelayOf = + typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : ''; + const rowSource = message.source; + return ( + rowMessageId === messageId && + rowRelayOf === relayOfMessageId && + this.normalizeComparableParticipant(message.from) === expectedFrom && + (rowSource === undefined || rowSource === 'runtime_delivery') && + this.taskRefsIncludeAll(message.taskRefs, taskRefs) + ); + }); + if (verified) { + result = { + found: true, + updated: true, + message: { ...verified, messageId }, + }; + return; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + } + throw new Error('Failed to verify inbox taskRefs merge'); + }); + }); + + return result; + } + + async correlateRuntimeDeliveryReply( + teamName: string, + request: CorrelateRuntimeDeliveryReplyRequest + ): Promise { + const inboxName = request.inboxName.trim(); + const messageId = request.messageId.trim(); + const relayOfMessageId = request.relayOfMessageId.trim(); + const expectedFrom = this.normalizeComparableParticipant(request.from); + if (!inboxName || !messageId || !relayOfMessageId || !expectedFrom) { + return { found: false, updated: false }; + } + + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`); + const taskRefs = this.normalizeTaskRefs(request.taskRefs); + let result: CorrelateRuntimeDeliveryReplyResult = { found: false, updated: false }; + await withFileLock(inboxPath, async () => { + await withInboxLock(inboxPath, async () => { + for (let attempt = 0; attempt < 3; attempt++) { + const list = await this.readInbox(inboxPath); + const index = list.findIndex((message) => { + const rowMessageId = + typeof message.messageId === 'string' ? message.messageId.trim() : ''; + const rowSource = message.source; + return ( + rowMessageId === messageId && + this.normalizeComparableParticipant(message.from) === expectedFrom && + (rowSource === undefined || rowSource === 'runtime_delivery') + ); + }); + if (index < 0) { + result = { found: false, updated: false }; + return; + } + + const existing = list[index]; + const merged = this.mergeTaskRefs(existing.taskRefs, taskRefs); + const currentRelayOf = + typeof existing.relayOfMessageId === 'string' ? existing.relayOfMessageId.trim() : ''; + if (currentRelayOf === relayOfMessageId && !merged.changed) { + result = { + found: true, + updated: false, + message: { ...existing, messageId }, + }; + return; + } + + const nextMessage: InboxMessage = { + ...existing, + relayOfMessageId, + ...(merged.taskRefs ? { taskRefs: merged.taskRefs } : {}), + }; + list[index] = nextMessage; + await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2)); + const written = await this.readInbox(inboxPath); + const verified = written.find((message) => { + const rowMessageId = + typeof message.messageId === 'string' ? message.messageId.trim() : ''; + const rowRelayOf = + typeof message.relayOfMessageId === 'string' ? message.relayOfMessageId.trim() : ''; + const rowSource = message.source; + return ( + rowMessageId === messageId && + rowRelayOf === relayOfMessageId && + this.normalizeComparableParticipant(message.from) === expectedFrom && + (rowSource === undefined || rowSource === 'runtime_delivery') && + this.taskRefsIncludeAll(message.taskRefs, taskRefs) + ); + }); + if (verified) { + result = { + found: true, + updated: true, + message: { ...verified, messageId }, + }; + return; + } + await new Promise((resolve) => setTimeout(resolve, 10 * 2 ** attempt)); + } + throw new Error('Failed to verify inbox runtime delivery correlation update'); + }); + }); + + return result; + } + + private findRuntimeDeliveryDuplicateIndex( messages: readonly InboxMessage[], payload: InboxMessage - ): InboxMessage | null { + ): number { if ( payload.source !== 'runtime_delivery' || typeof payload.relayOfMessageId !== 'string' || payload.relayOfMessageId.trim().length === 0 ) { - return null; + return -1; } const relayOfMessageId = payload.relayOfMessageId.trim(); @@ -96,21 +320,83 @@ export class TeamInboxWriter { const to = this.normalizeComparableParticipant(payload.to); const text = this.normalizeComparableText(payload.text); if (!from || !to || !text) { - return null; + return -1; } - return ( - messages.find( - (candidate) => - candidate.source === 'runtime_delivery' && - (candidate.relayOfMessageId ?? '').trim() === relayOfMessageId && - this.normalizeComparableParticipant(candidate.from) === from && - this.normalizeComparableParticipant(candidate.to) === to && - this.normalizeComparableText(candidate.text) === text - ) ?? null + return messages.findIndex( + (candidate) => + candidate.source === 'runtime_delivery' && + (candidate.relayOfMessageId ?? '').trim() === relayOfMessageId && + this.normalizeComparableParticipant(candidate.from) === from && + this.normalizeComparableParticipant(candidate.to) === to && + this.normalizeComparableText(candidate.text) === text ); } + private mergeTaskRefs( + existing: readonly TaskRef[] | undefined, + incoming: readonly TaskRef[] | undefined + ): { changed: boolean; taskRefs?: TaskRef[] } { + const normalizedExisting = this.normalizeTaskRefs(existing); + const normalizedIncoming = this.normalizeTaskRefs(incoming); + if (normalizedIncoming.length === 0) { + return { + changed: false, + taskRefs: normalizedExisting.length ? normalizedExisting : undefined, + }; + } + + const seen = new Set(normalizedExisting.map((taskRef) => this.taskRefKey(taskRef))); + const merged = [...normalizedExisting]; + let changed = false; + for (const taskRef of normalizedIncoming) { + const key = this.taskRefKey(taskRef); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(taskRef); + changed = true; + } + return { changed, taskRefs: merged.length ? merged : undefined }; + } + + private taskRefsIncludeAll( + actual: readonly TaskRef[] | undefined, + expected: readonly TaskRef[] + ): boolean { + const actualKeys = new Set( + this.normalizeTaskRefs(actual).map((taskRef) => this.taskRefKey(taskRef)) + ); + return this.normalizeTaskRefs(expected).every((taskRef) => + actualKeys.has(this.taskRefKey(taskRef)) + ); + } + + private normalizeTaskRefs(taskRefs: readonly TaskRef[] | undefined): TaskRef[] { + if (!Array.isArray(taskRefs)) { + return []; + } + const normalized: TaskRef[] = []; + for (const rawTaskRef of taskRefs as readonly unknown[]) { + if (!rawTaskRef || typeof rawTaskRef !== 'object') { + continue; + } + const taskRef = rawTaskRef as Record; + const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : ''; + const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : ''; + const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : ''; + if (teamName && taskId && displayId) { + normalized.push({ teamName, taskId, displayId }); + } + } + return normalized; + } + + private taskRefKey(taskRef: TaskRef): string { + return `${taskRef.teamName.trim()}\u0000${taskRef.taskId.trim()}\u0000${taskRef.displayId.trim()}`; + } + private normalizeComparableParticipant(value: unknown): string { return typeof value === 'string' ? value.trim().toLowerCase() : ''; } diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index e4c648df..c1c8dd20 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -499,7 +499,12 @@ export class TeamMemberLogsFinder { const startMs = Date.parse(i.startedAt); const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; - const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + const endMs = + i.completedAt === undefined + ? null + : Number.isFinite(endMsRaw) + ? Math.max(endMsRaw, startMs) + : startMs; return Number.isFinite(startMs) ? { startMs, endMs } : null; }) .filter((v): v is { startMs: number; endMs: number | null } => v !== null) @@ -516,12 +521,12 @@ export class TeamMemberLogsFinder { : []; const filteredOwnerLogs = ownerLogs.filter((log) => { - if (log.isOngoing) return true; const startMs = new Date(log.startTime).getTime(); if (!Number.isFinite(startMs)) return false; const durationMs = typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; - const endMs = startMs + durationMs; + const rawEndMs = startMs + durationMs; + const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs; if (effectiveIntervals.length > 0) { return this.logOverlapsIntervals( @@ -533,6 +538,7 @@ export class TeamMemberLogsFinder { ); } + if (log.isOngoing) return true; return startMs >= now - fallbackRecentMs; }); const seen = new Set(); @@ -740,7 +746,12 @@ export class TeamMemberLogsFinder { const startMs = Date.parse(i.startedAt); const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN; - const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null; + const endMs = + i.completedAt === undefined + ? null + : Number.isFinite(endMsRaw) + ? Math.max(endMsRaw, startMs) + : startMs; return Number.isFinite(startMs) ? { startMs, endMs } : null; }) .filter((v): v is { startMs: number; endMs: number | null } => v !== null) @@ -757,28 +768,27 @@ export class TeamMemberLogsFinder { for (const log of ownerLogs) { if (!log.filePath) continue; - if (!log.isOngoing) { - const startMs = new Date(log.startTime).getTime(); - if (!Number.isFinite(startMs)) continue; - const durationMs = - typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; - const endMs = startMs + durationMs; + const startMs = new Date(log.startTime).getTime(); + if (!Number.isFinite(startMs)) continue; + const durationMs = + typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0; + const rawEndMs = startMs + durationMs; + const endMs = log.isOngoing ? Math.max(rawEndMs, now) : rawEndMs; - if (effectiveIntervals.length > 0) { - if ( - !this.logOverlapsIntervals( - startMs, - endMs, - effectiveIntervals, - now, - TASK_LOG_INTERVAL_GRACE_MS - ) - ) { - continue; - } - } else if (startMs < now - fallbackRecentMs) { + if (effectiveIntervals.length > 0) { + if ( + !this.logOverlapsIntervals( + startMs, + endMs, + effectiveIntervals, + now, + TASK_LOG_INTERVAL_GRACE_MS + ) + ) { continue; } + } else if (!log.isOngoing && startMs < now - fallbackRecentMs) { + continue; } pushRef( diff --git a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts index 265f336c..e93e4c6e 100644 --- a/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts +++ b/src/main/services/team/TeamMemberRuntimeAdvisoryService.ts @@ -98,10 +98,14 @@ const PROTOCOL_PROOF_MISSING_TOKENS = [ 'plain_text_ack_only_still_requires_answer', 'visible_reply_destination_not_found_yet', 'visible_reply_missing_relayofmessageid', + 'visible_reply_missing_task_refs', + 'visible_reply_missing_task_refs_after_merge', + 'visible_reply_task_refs_merge_failed', 'did not create a visible reply', 'did not create a visible message_send reply', 'did not create a visible reply or task progress proof', 'without the required relayofmessageid correlation', + 'without the required taskrefs metadata', ]; const logger = createLogger('Service:TeamMemberRuntimeAdvisory'); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ae69dbfe..6849857e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -523,6 +523,7 @@ const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/; const RUN_TIMEOUT_MS = 300_000; const VERIFY_TIMEOUT_MS = 15_000; const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000; +const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000; function asRuntimeRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -1982,6 +1983,48 @@ function nowIso(): string { return new Date().toISOString(); } +function parseOptionalIsoMs(value: string | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function deriveTaskActivityPauseAt(previous: MemberSpawnStatusEntry, fallbackAt: string): string { + const fallbackMs = parseOptionalIsoMs(fallbackAt); + const explicitEvidenceMs = Math.max( + parseOptionalIsoMs(previous.lastHeartbeatAt), + parseOptionalIsoMs(previous.livenessLastCheckedAt) + ); + const evidenceMs = + explicitEvidenceMs > 0 ? explicitEvidenceMs : parseOptionalIsoMs(previous.updatedAt); + if (evidenceMs <= 0 || fallbackMs <= 0) { + return fallbackAt; + } + const boundedEvidenceMs = Math.min(evidenceMs, fallbackMs); + const closeMs = Math.max( + boundedEvidenceMs, + Math.min(fallbackMs, boundedEvidenceMs + TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS) + ); + return new Date(closeMs).toISOString(); +} + +function deriveTaskActivityResumeAt( + previous: MemberSpawnStatusEntry, + evidenceAt: string, + fallbackAt: string +): string { + const fallbackMs = parseOptionalIsoMs(fallbackAt); + const evidenceMs = parseOptionalIsoMs(evidenceAt); + const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt); + if (evidenceMs <= 0 || fallbackMs <= 0) { + return fallbackAt; + } + if (previousUpdatedMs > 0 && evidenceMs < previousUpdatedMs) { + return fallbackAt; + } + return new Date(Math.min(evidenceMs, fallbackMs)).toISOString(); +} + function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { const updatedAt = nowIso(); return { @@ -5242,6 +5285,16 @@ interface OpenCodeMemberInboxDelivery { diagnostics?: string[]; } +type OpenCodeVisibleReplyCorrelation = NonNullable< + OpenCodePromptDeliveryLedgerRecord['visibleReplyCorrelation'] +>; + +interface OpenCodeRecoveredVisibleReplyProof { + visibleReply: OpenCodeVisibleReplyProof; + visibleReplyCorrelation: OpenCodeVisibleReplyCorrelation; + diagnostics: string[]; +} + interface OpenCodeMemberDirectory { config: TeamConfig | null; teamMeta: Awaited> | null; @@ -5454,6 +5507,10 @@ export class TeamProvisioningService { private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService(); private readonly crashRepairedActivityIntervalsByTeam = new Set(); + private readonly pendingCrashRepairSnapshotByTeam = new Map< + string, + PersistedTeamLaunchSnapshot | null + >(); private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; private helpOutputCache: string | null = null; private helpOutputCacheTime = 0; @@ -5510,10 +5567,51 @@ export class TeamProvisioningService { private repairStaleTaskActivityIntervalsOnce( teamName: string, launchSnapshot?: PersistedTeamLaunchSnapshot | null - ): void { - if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return; - this.taskActivityIntervalService.repairStaleIntervalsAfterCrash(teamName, launchSnapshot); + ): boolean { + if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return true; + const repairSnapshot = this.pendingCrashRepairSnapshotByTeam.has(teamName) + ? this.pendingCrashRepairSnapshotByTeam.get(teamName) + : (launchSnapshot ?? null); + const result = this.taskActivityIntervalService.repairStaleIntervalsAfterCrash( + teamName, + repairSnapshot + ); + if (result.failed) { + if (!this.pendingCrashRepairSnapshotByTeam.has(teamName)) { + this.pendingCrashRepairSnapshotByTeam.set(teamName, launchSnapshot ?? null); + } + return false; + } + this.pendingCrashRepairSnapshotByTeam.delete(teamName); this.crashRepairedActivityIntervalsByTeam.add(teamName); + return true; + } + + private async readTaskActivityRepairLaunchSnapshot( + teamName: string + ): Promise { + const [bootstrapSnapshot, launchSnapshot] = await Promise.all([ + readBootstrapLaunchSnapshot(teamName).catch(() => null), + this.launchStateStore.read(teamName).catch(() => null), + ]); + return choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot); + } + + async repairStaleTaskActivityIntervalsBeforeSnapshot(teamName: string): Promise { + if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) { + return; + } + + const runId = this.getTrackedRunId(teamName); + if (runId && this.runs.has(runId)) { + return; + } + + const repairSnapshot = await this.readTaskActivityRepairLaunchSnapshot(teamName); + const repaired = this.repairStaleTaskActivityIntervalsOnce(teamName, repairSnapshot); + if (!repaired) { + throw new Error(`Task activity interval repair failed before snapshot for team ${teamName}`); + } } private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void { @@ -6728,16 +6826,19 @@ export class TeamProvisioningService { return this.isOpenCodePlainTextResponseReadCommitAllowed({ actionMode: input.actionMode, taskRefs: input.taskRefs, + visibleReply: input.visibleReply, ledgerRecord: input.ledgerRecord, }); } if (state === 'responded_visible_message') { - return isOpenCodeVisibleReplyReadCommitAllowed({ - actionMode: input.actionMode, - taskRefs: input.taskRefs, - visibleReply: input.visibleReply, - transcriptOnlyVisibleReply: !input.visibleReply, - }); + return ( + isOpenCodeVisibleReplyReadCommitAllowed({ + actionMode: input.actionMode, + taskRefs: input.taskRefs, + visibleReply: input.visibleReply, + transcriptOnlyVisibleReply: !input.visibleReply, + }) && this.openCodeTaskRefsIncludeAll(input.visibleReply?.message.taskRefs, input.taskRefs) + ); } const hasTaskRefs = (input.taskRefs ?? []).length > 0; if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') { @@ -6774,6 +6875,14 @@ export class TeamProvisioningService { }); } + private hasOpenCodeObservedMessageSendToolCall( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + return (ledgerRecord?.observedToolCallNames ?? []).some( + (toolName) => this.normalizeOpenCodeObservedToolName(toolName) === 'message_send' + ); + } + private normalizeOpenCodeObservedToolName(toolName: string): string { return toolName .trim() @@ -6787,12 +6896,15 @@ export class TeamProvisioningService { private isOpenCodePlainTextResponseReadCommitAllowed(input: { actionMode?: AgentActionMode; taskRefs?: TaskRef[]; + visibleReply?: OpenCodeVisibleReplyProof | null; ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; }): boolean { if (this.isOpenCodeDirectUserPromptDelivery(input.ledgerRecord)) { - return Boolean( - input.ledgerRecord?.visibleReplyInbox?.trim() && - input.ledgerRecord?.visibleReplyMessageId?.trim() + return ( + Boolean( + input.ledgerRecord?.visibleReplyInbox?.trim() && + input.ledgerRecord?.visibleReplyMessageId?.trim() + ) && this.openCodeTaskRefsIncludeAll(input.visibleReply?.message.taskRefs, input.taskRefs) ); } const preview = input.ledgerRecord?.observedAssistantPreview?.trim(); @@ -6815,11 +6927,27 @@ export class TeamProvisioningService { }): string { const record = input.ledgerRecord; const state = input.responseState ?? record?.responseState; - if (record?.lastReason === 'visible_reply_ack_only_still_requires_answer') { - return 'visible_reply_ack_only_still_requires_answer'; + if (state === 'responded_visible_message' && !input.visibleReply) { + return 'visible_reply_destination_not_found_yet'; + } + if ( + state === 'responded_visible_message' && + !this.openCodeTaskRefsIncludeAll(input.visibleReply?.message.taskRefs, input.taskRefs) + ) { + return 'visible_reply_missing_task_refs'; } if (state === 'responded_plain_text') { const preview = record?.observedAssistantPreview?.trim(); + if ( + this.isOpenCodeDirectUserPromptDelivery(record) && + input.visibleReply && + !this.openCodeTaskRefsIncludeAll(input.visibleReply.message.taskRefs, input.taskRefs) + ) { + return 'visible_reply_missing_task_refs'; + } + if (record?.lastReason === 'visible_reply_ack_only_still_requires_answer') { + return 'visible_reply_ack_only_still_requires_answer'; + } if ( preview && !isOpenCodeVisibleReplySemanticallySufficient({ @@ -6838,8 +6966,8 @@ export class TeamProvisioningService { return 'plain_text_visible_reply_not_materialized_yet'; } } - if (state === 'responded_visible_message' && !input.visibleReply) { - return 'visible_reply_destination_not_found_yet'; + if (record?.lastReason === 'visible_reply_ack_only_still_requires_answer') { + return 'visible_reply_ack_only_still_requires_answer'; } if (state === 'responded_non_visible_tool' || state === 'responded_tool_call') { const hasTaskRefs = (input.taskRefs ?? []).length > 0; @@ -6904,7 +7032,8 @@ export class TeamProvisioningService { } if ( input.ledgerRecord.lastReason === 'visible_reply_ack_only_still_requires_answer' || - input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' + input.ledgerRecord.lastReason === 'plain_text_ack_only_still_requires_answer' || + input.ledgerRecord.lastReason === 'visible_reply_missing_task_refs' ) { return true; } @@ -6989,6 +7118,18 @@ export class TeamProvisioningService { return ledgerRecord?.replyRecipient?.trim().toLowerCase() === 'user'; } + private canMaterializeOpenCodePlainTextReply( + ledgerRecord: OpenCodePromptDeliveryLedgerRecord + ): boolean { + if (ledgerRecord.responseState === 'responded_plain_text') { + return true; + } + return ( + ledgerRecord.responseState === 'tool_error' && + this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord) + ); + } + private isOpenCodePromptDeliveryWatchdogEnabled(): boolean { const enabled = process.env.CLAUDE_TEAM_OPENCODE_PROMPT_DELIVERY_WATCHDOG !== '0'; if (!enabled && !this.openCodePromptDeliveryWatchdogDisabledLogged) { @@ -7068,6 +7209,235 @@ export class TeamProvisioningService { return null; } + private isOpenCodeVisibleReplyTimestampEligible(input: { + message: InboxMessage; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + }): boolean { + const messageMs = Date.parse(input.message.timestamp); + const inboxMs = Date.parse(input.ledgerRecord.inboxTimestamp); + if (!Number.isFinite(messageMs) || !Number.isFinite(inboxMs)) { + return true; + } + return messageMs + 5_000 >= inboxMs; + } + + private isOpenCodeRecoveredVisibleReplyCandidate(input: { + message: InboxMessage & { messageId: string }; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + from: string; + requireTaskRefs: boolean; + }): boolean { + const expectedFrom = input.from.trim().toLowerCase(); + if (!expectedFrom || input.message.from.trim().toLowerCase() !== expectedFrom) { + return false; + } + if (input.message.source !== undefined && input.message.source !== 'runtime_delivery') { + return false; + } + if ( + input.requireTaskRefs && + !this.openCodeTaskRefsIncludeAll(input.message.taskRefs, input.ledgerRecord.taskRefs) + ) { + return false; + } + if ( + !this.isOpenCodeVisibleReplyTimestampEligible({ + message: input.message, + ledgerRecord: input.ledgerRecord, + }) + ) { + return false; + } + return isOpenCodeVisibleReplySemanticallySufficient({ + actionMode: input.ledgerRecord.actionMode, + taskRefs: input.ledgerRecord.taskRefs, + text: input.message.text, + summary: input.message.summary, + }).sufficient; + } + + private async correlateOpenCodeRecoveredVisibleReply(input: { + teamName: string; + inboxName: string; + memberName: string; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + visibleReply: OpenCodeVisibleReplyProof; + diagnostic: string; + }): Promise { + const expectedRelayOfMessageId = input.ledgerRecord.inboxMessageId.trim(); + const currentRelayOfMessageId = + typeof input.visibleReply.message.relayOfMessageId === 'string' + ? input.visibleReply.message.relayOfMessageId.trim() + : ''; + if (currentRelayOfMessageId === expectedRelayOfMessageId) { + return { + visibleReply: input.visibleReply, + visibleReplyCorrelation: 'relayOfMessageId', + diagnostics: [input.diagnostic], + }; + } + + try { + const correlated = await this.inboxWriter.correlateRuntimeDeliveryReply(input.teamName, { + inboxName: input.inboxName, + messageId: input.visibleReply.message.messageId, + relayOfMessageId: expectedRelayOfMessageId, + from: input.memberName, + taskRefs: input.ledgerRecord.taskRefs, + }); + if (correlated.message) { + const visibleReply = { + ...input.visibleReply, + message: correlated.message, + }; + if (correlated.updated) { + this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReply.message); + } + return { + visibleReply, + visibleReplyCorrelation: 'relayOfMessageId', + diagnostics: [ + input.diagnostic, + correlated.updated + ? 'opencode_visible_reply_relayOfMessageId_repaired' + : 'opencode_visible_reply_relayOfMessageId_already_correlated', + ], + }; + } + return { + visibleReply: input.visibleReply, + visibleReplyCorrelation: 'direct_child_message_send', + diagnostics: [input.diagnostic, 'opencode_visible_reply_relayOfMessageId_repair_not_found'], + }; + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to repair OpenCode visible reply relayOfMessageId for ${input.memberName}/${expectedRelayOfMessageId}: ${getErrorMessage(error)}` + ); + return { + visibleReply: input.visibleReply, + visibleReplyCorrelation: 'direct_child_message_send', + diagnostics: [input.diagnostic, 'opencode_visible_reply_relayOfMessageId_repair_failed'], + }; + } + } + + private async findOpenCodeVisibleReplyByObservedMessageId(input: { + teamName: string; + replyRecipient?: string | null; + from: string; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + }): Promise { + const expectedMessageId = input.ledgerRecord.visibleReplyMessageId?.trim(); + if (!expectedMessageId) { + return null; + } + const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({ + teamName: input.teamName, + replyRecipient: input.replyRecipient, + includeUserFallbackForLeadRecipient: true, + }); + for (const inboxName of candidates) { + const messages = await this.inboxReader + .getMessagesFor(input.teamName, inboxName) + .catch(() => []); + const match = messages.find((message): message is InboxMessage & { messageId: string } => { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + return ( + messageId === expectedMessageId && + this.isOpenCodeRecoveredVisibleReplyCandidate({ + message: { ...message, messageId }, + ledgerRecord: input.ledgerRecord, + from: input.from, + requireTaskRefs: false, + }) + ); + }); + if (!match) { + continue; + } + return await this.correlateOpenCodeRecoveredVisibleReply({ + teamName: input.teamName, + inboxName, + memberName: input.from, + ledgerRecord: input.ledgerRecord, + visibleReply: { + inboxName, + message: { ...match, messageId: match.messageId.trim() }, + missingRuntimeDeliverySource: match.source !== 'runtime_delivery', + }, + diagnostic: 'opencode_visible_reply_recovered_by_observed_message_id', + }); + } + return null; + } + + private async findOpenCodeVisibleReplyByTaskRefs(input: { + teamName: string; + replyRecipient?: string | null; + from: string; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + }): Promise { + if (this.normalizeOpenCodeTaskRefsForComparison(input.ledgerRecord.taskRefs).length === 0) { + return null; + } + const candidates = await this.getOpenCodeVisibleReplyInboxCandidates({ + teamName: input.teamName, + replyRecipient: input.replyRecipient, + includeUserFallbackForLeadRecipient: true, + }); + const matches: OpenCodeVisibleReplyProof[] = []; + for (const inboxName of candidates) { + const messages = await this.inboxReader + .getMessagesFor(input.teamName, inboxName) + .catch(() => []); + for (const message of messages) { + const messageId = typeof message.messageId === 'string' ? message.messageId.trim() : ''; + if (!messageId) { + continue; + } + const candidate = { ...message, messageId }; + if ( + this.isOpenCodeRecoveredVisibleReplyCandidate({ + message: candidate, + ledgerRecord: input.ledgerRecord, + from: input.from, + requireTaskRefs: true, + }) + ) { + matches.push({ + inboxName, + message: candidate, + missingRuntimeDeliverySource: candidate.source !== 'runtime_delivery', + }); + } + } + } + const match = matches.sort((left, right) => { + const leftMs = Date.parse(left.message.timestamp); + const rightMs = Date.parse(right.message.timestamp); + const leftValid = Number.isFinite(leftMs); + const rightValid = Number.isFinite(rightMs); + if (leftValid && rightValid && leftMs !== rightMs) { + return leftMs - rightMs; + } + if (leftValid !== rightValid) { + return leftValid ? -1 : 1; + } + return left.message.messageId.localeCompare(right.message.messageId); + })[0]; + if (!match) { + return null; + } + return await this.correlateOpenCodeRecoveredVisibleReply({ + teamName: input.teamName, + inboxName: match.inboxName, + memberName: input.from, + ledgerRecord: input.ledgerRecord, + visibleReply: match, + diagnostic: 'opencode_visible_reply_recovered_by_task_refs', + }); + } + private async getOpenCodeVisibleReplyInboxCandidates(input: { teamName: string; replyRecipient?: string | null; @@ -7115,6 +7485,114 @@ export class TeamProvisioningService { ); } + private openCodeTaskRefsIncludeAll( + actual: readonly TaskRef[] | undefined, + expected: readonly TaskRef[] | undefined + ): boolean { + const normalizedExpected = this.normalizeOpenCodeTaskRefsForComparison(expected); + if (normalizedExpected.length === 0) { + return true; + } + const actualKeys = new Set( + this.normalizeOpenCodeTaskRefsForComparison(actual).map((taskRef) => + this.openCodeTaskRefKey(taskRef) + ) + ); + return normalizedExpected.every((taskRef) => actualKeys.has(this.openCodeTaskRefKey(taskRef))); + } + + private normalizeOpenCodeTaskRefsForComparison( + taskRefs: readonly TaskRef[] | undefined + ): TaskRef[] { + if (!Array.isArray(taskRefs)) { + return []; + } + const normalized: TaskRef[] = []; + for (const rawTaskRef of taskRefs as readonly unknown[]) { + if (!rawTaskRef || typeof rawTaskRef !== 'object') { + continue; + } + const taskRef = rawTaskRef as Record; + const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : ''; + const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : ''; + const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : ''; + if (teamName && taskId && displayId) { + normalized.push({ teamName, taskId, displayId }); + } + } + return normalized; + } + + private openCodeTaskRefKey(taskRef: TaskRef): string { + return `${taskRef.teamName.trim()}\u0000${taskRef.taskId.trim()}\u0000${taskRef.displayId.trim()}`; + } + + private async ensureOpenCodeVisibleReplyTaskRefs(input: { + teamName: string; + memberName: string; + ledgerRecord: OpenCodePromptDeliveryLedgerRecord; + visibleReply: OpenCodeVisibleReplyProof; + }): Promise<{ visibleReply: OpenCodeVisibleReplyProof; diagnostics: string[] }> { + const taskRefs = this.normalizeOpenCodeTaskRefsForComparison(input.ledgerRecord.taskRefs); + if (taskRefs.length === 0) { + return { visibleReply: input.visibleReply, diagnostics: [] }; + } + if (this.openCodeTaskRefsIncludeAll(input.visibleReply.message.taskRefs, taskRefs)) { + return { visibleReply: input.visibleReply, diagnostics: [] }; + } + + const messageId = input.visibleReply.message.messageId.trim(); + const relayOfMessageId = + typeof input.visibleReply.message.relayOfMessageId === 'string' + ? input.visibleReply.message.relayOfMessageId.trim() + : ''; + if (!messageId || relayOfMessageId !== input.ledgerRecord.inboxMessageId.trim()) { + return { + visibleReply: input.visibleReply, + diagnostics: ['visible_reply_missing_task_refs'], + }; + } + + try { + const merged = await this.inboxWriter.mergeRuntimeDeliveryTaskRefs(input.teamName, { + inboxName: input.visibleReply.inboxName, + messageId, + relayOfMessageId, + from: input.memberName, + taskRefs, + }); + if (merged.message && this.openCodeTaskRefsIncludeAll(merged.message.taskRefs, taskRefs)) { + const visibleReply = { + ...input.visibleReply, + message: merged.message, + }; + if (merged.updated) { + this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReply.message); + } + return { + visibleReply, + diagnostics: merged.updated + ? ['opencode_runtime_delivery_task_refs_inherited_from_relay'] + : [], + }; + } + return { + visibleReply: input.visibleReply, + diagnostics: merged.found + ? ['visible_reply_missing_task_refs_after_merge'] + : ['visible_reply_missing_task_refs'], + }; + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to merge OpenCode runtime delivery taskRefs for ${input.memberName}/${input.ledgerRecord.inboxMessageId}: ${getErrorMessage(error)}` + ); + return { + visibleReply: input.visibleReply, + diagnostics: ['visible_reply_task_refs_merge_failed'], + }; + } + } + private async applyOpenCodeVisibleDestinationProof(input: { ledger: OpenCodePromptDeliveryLedgerStore; ledgerRecord: OpenCodePromptDeliveryLedgerRecord; @@ -7125,7 +7603,7 @@ export class TeamProvisioningService { ledgerRecord: OpenCodePromptDeliveryLedgerRecord; visibleReply: OpenCodeVisibleReplyProof | null; }> { - const visibleReply = await this.findOpenCodeVisibleReplyByRelayOfMessageId({ + let visibleReply = await this.findOpenCodeVisibleReplyByRelayOfMessageId({ teamName: input.teamName, replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient, from: input.memberName, @@ -7137,26 +7615,70 @@ export class TeamProvisioningService { allowUserFallbackForLeadRecipient: input.ledgerRecord.visibleReplyCorrelation === 'relayOfMessageId', }); + let visibleReplyCorrelation: OpenCodeVisibleReplyCorrelation = 'relayOfMessageId'; + let recoveryDiagnostics: string[] = []; + if (!visibleReply) { + const recoveredByMessageId = await this.findOpenCodeVisibleReplyByObservedMessageId({ + teamName: input.teamName, + replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient, + from: input.memberName, + ledgerRecord: input.ledgerRecord, + }); + if (recoveredByMessageId) { + visibleReply = recoveredByMessageId.visibleReply; + visibleReplyCorrelation = recoveredByMessageId.visibleReplyCorrelation; + recoveryDiagnostics = recoveredByMessageId.diagnostics; + } + } + if (!visibleReply) { + const recoveredByTaskRefs = await this.findOpenCodeVisibleReplyByTaskRefs({ + teamName: input.teamName, + replyRecipient: input.replyRecipient ?? input.ledgerRecord.replyRecipient, + from: input.memberName, + ledgerRecord: input.ledgerRecord, + }); + if (recoveredByTaskRefs) { + visibleReply = recoveredByTaskRefs.visibleReply; + visibleReplyCorrelation = recoveredByTaskRefs.visibleReplyCorrelation; + recoveryDiagnostics = recoveredByTaskRefs.diagnostics; + } + } if (!visibleReply) { return { ledgerRecord: input.ledgerRecord, visibleReply: null }; } - const semantic = isOpenCodeVisibleReplyReadCommitAllowed({ - actionMode: input.ledgerRecord.actionMode, - taskRefs: input.ledgerRecord.taskRefs, + const enriched = await this.ensureOpenCodeVisibleReplyTaskRefs({ + teamName: input.teamName, + memberName: input.memberName, + ledgerRecord: input.ledgerRecord, visibleReply, }); + const visibleReplyForProof = enriched.visibleReply; + const taskRefsSatisfied = this.openCodeTaskRefsIncludeAll( + visibleReplyForProof.message.taskRefs, + input.ledgerRecord.taskRefs + ); + const semantic = + isOpenCodeVisibleReplyReadCommitAllowed({ + actionMode: input.ledgerRecord.actionMode, + taskRefs: input.ledgerRecord.taskRefs, + visibleReply: visibleReplyForProof, + }) && taskRefsSatisfied; const ledgerRecord = await input.ledger.applyDestinationProof({ id: input.ledgerRecord.id, - visibleReplyInbox: visibleReply.inboxName, - visibleReplyMessageId: visibleReply.message.messageId, - visibleReplyCorrelation: 'relayOfMessageId', + visibleReplyInbox: visibleReplyForProof.inboxName, + visibleReplyMessageId: visibleReplyForProof.message.messageId, + visibleReplyCorrelation, semanticallySufficient: semantic, - diagnostics: visibleReply.missingRuntimeDeliverySource - ? ['visible_reply_missing_runtime_delivery_source'] - : [], + diagnostics: [ + ...recoveryDiagnostics, + ...(visibleReplyForProof.missingRuntimeDeliverySource + ? ['visible_reply_missing_runtime_delivery_source'] + : []), + ...enriched.diagnostics, + ], observedAt: nowIso(), }); - return { ledgerRecord, visibleReply }; + return { ledgerRecord, visibleReply: visibleReplyForProof }; } private buildOpenCodePlainTextVisibleReplyMessageId( @@ -7184,8 +7706,11 @@ export class TeamProvisioningService { if (input.visibleReply) { return { ledgerRecord: input.ledgerRecord, visibleReply: input.visibleReply }; } + const materializedFromMessageSendToolError = + input.ledgerRecord.responseState === 'tool_error' && + this.hasOpenCodeObservedMessageSendToolCall(input.ledgerRecord); if ( - input.ledgerRecord.responseState !== 'responded_plain_text' || + !this.canMaterializeOpenCodePlainTextReply(input.ledgerRecord) || !this.isOpenCodeDirectUserPromptDelivery(input.ledgerRecord) || input.ledgerRecord.visibleReplyMessageId || input.ledgerRecord.visibleReplyInbox @@ -7216,19 +7741,35 @@ export class TeamProvisioningService { }); if (existing) { + const enriched = await this.ensureOpenCodeVisibleReplyTaskRefs({ + teamName: input.teamName, + memberName: input.memberName, + ledgerRecord: input.ledgerRecord, + visibleReply: existing, + }); + const existingForProof = enriched.visibleReply; const ledgerRecord = await input.ledger.applyDestinationProof({ id: input.ledgerRecord.id, - visibleReplyInbox: existing.inboxName, - visibleReplyMessageId: existing.message.messageId, + visibleReplyInbox: existingForProof.inboxName, + visibleReplyMessageId: existingForProof.message.messageId, visibleReplyCorrelation: 'plain_assistant_text', - semanticallySufficient: true, - diagnostics: existing.missingRuntimeDeliverySource - ? ['plain_text_visible_reply_missing_runtime_delivery_source'] - : [], + semanticallySufficient: this.openCodeTaskRefsIncludeAll( + existingForProof.message.taskRefs, + input.ledgerRecord.taskRefs + ), + diagnostics: [ + ...(materializedFromMessageSendToolError + ? ['opencode_message_send_tool_error_plain_text_reply_materialized'] + : []), + ...(existingForProof.missingRuntimeDeliverySource + ? ['plain_text_visible_reply_missing_runtime_delivery_source'] + : []), + ...enriched.diagnostics, + ], observedAt: nowIso(), }); - this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, existing.message); - return { ledgerRecord, visibleReply: existing }; + this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, existingForProof.message); + return { ledgerRecord, visibleReply: existingForProof }; } const timestamp = @@ -7247,6 +7788,7 @@ export class TeamProvisioningService { messageId, relayOfMessageId: input.ledgerRecord.inboxMessageId, source: 'runtime_delivery', + taskRefs: input.ledgerRecord.taskRefs, }); const visibleReply: OpenCodeVisibleReplyProof = { inboxName: 'user', @@ -7260,6 +7802,7 @@ export class TeamProvisioningService { messageId: written.messageId, relayOfMessageId: input.ledgerRecord.inboxMessageId, source: 'runtime_delivery', + taskRefs: input.ledgerRecord.taskRefs, }, }; const ledgerRecord = await input.ledger.applyDestinationProof({ @@ -7268,9 +7811,14 @@ export class TeamProvisioningService { visibleReplyMessageId: written.messageId, visibleReplyCorrelation: 'plain_assistant_text', semanticallySufficient: true, - diagnostics: written.deduplicated - ? ['opencode_plain_text_reply_materialized_deduplicated'] - : ['opencode_plain_text_reply_materialized_to_user_inbox'], + diagnostics: [ + ...(materializedFromMessageSendToolError + ? ['opencode_message_send_tool_error_plain_text_reply_materialized'] + : []), + written.deduplicated + ? 'opencode_plain_text_reply_materialized_deduplicated' + : 'opencode_plain_text_reply_materialized_to_user_inbox', + ], observedAt: nowIso(), }); this.emitRuntimeDeliveryReplyAdvisoryRefresh(input.teamName, visibleReply.message); @@ -7438,7 +7986,15 @@ export class TeamProvisioningService { private getOpenCodeDeliveryNextDelayMs(input: { responseState?: NonNullable['state']; retry: boolean; + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null; }): number { + if ( + input.retry && + input.responseState === 'tool_error' && + this.hasOpenCodeObservedMessageSendToolCall(input.ledgerRecord) + ) { + return OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS; + } if (input.retry) { return OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS; } @@ -7483,6 +8039,7 @@ export class TeamProvisioningService { const delayMs = this.getOpenCodeDeliveryNextDelayMs({ responseState: input.ledgerRecord.responseState, retry: input.retry, + ledgerRecord: input.ledgerRecord, }); const nextAttemptAt = new Date(Date.now() + delayMs).toISOString(); const ledgerRecord = await input.ledger.markNextAttemptScheduled({ @@ -11154,6 +11711,13 @@ export class TeamProvisioningService { livenessLastCheckedAt: input.observedAt, updatedAt: input.observedAt, }; + this.syncMemberTaskActivityForRuntimeTransition( + run, + input.memberName, + previousStatus, + nextStatus, + input.observedAt + ); run.memberSpawnStatuses.set(input.memberName, nextStatus); run.pendingMemberRestarts?.delete(input.memberName); this.syncMemberLaunchGraceCheck(run, input.memberName, nextStatus); @@ -12103,6 +12667,44 @@ export class TeamProvisioningService { } } + private pauseMemberTaskActivityForRuntimeLoss( + run: ProvisioningRun, + memberName: string, + previous: MemberSpawnStatusEntry, + observedAt: string + ): void { + if (previous.runtimeAlive !== true) return; + this.taskActivityIntervalService.pauseActiveIntervalsForMember( + run.teamName, + memberName, + deriveTaskActivityPauseAt(previous, observedAt) + ); + } + + private syncMemberTaskActivityForRuntimeTransition( + run: ProvisioningRun, + memberName: string, + previous: MemberSpawnStatusEntry, + next: MemberSpawnStatusEntry, + observedAt: string + ): void { + if (previous.runtimeAlive === true && next.runtimeAlive !== true) { + this.pauseMemberTaskActivityForRuntimeLoss(run, memberName, previous, observedAt); + } else if (previous.runtimeAlive !== true && next.runtimeAlive === true) { + const nextUpdatedMs = parseOptionalIsoMs(next.updatedAt); + const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt); + const resumeFallbackAt = + nextUpdatedMs > 0 && (previousUpdatedMs <= 0 || nextUpdatedMs > previousUpdatedMs) + ? next.updatedAt + : nowIso(); + this.taskActivityIntervalService.resumeActiveIntervalsForMember( + run.teamName, + memberName, + deriveTaskActivityResumeAt(previous, observedAt, resumeFallbackAt) + ); + } + } + /** * Update spawn status for a specific team member and emit a change event. */ @@ -12138,6 +12740,7 @@ export class TeamProvisioningService { }; if (status === 'spawning') { + const pendingRestart = run.pendingMemberRestarts?.get(memberName); next.skippedForLaunch = false; next.skipReason = undefined; next.skippedAt = undefined; @@ -12153,8 +12756,13 @@ export class TeamProvisioningService { next.runtimeDiagnostic = undefined; next.runtimeDiagnosticSeverity = undefined; next.livenessLastCheckedAt = undefined; - next.firstSpawnAcceptedAt = undefined; + next.firstSpawnAcceptedAt = pendingRestart?.requestedAt; next.lastHeartbeatAt = undefined; + if (pendingRestart) { + next.runtimeDiagnostic = + 'Manual restart is already in progress; waiting for teammate bootstrap.'; + next.runtimeDiagnosticSeverity = 'info'; + } next.launchState = 'starting'; } else if (status === 'waiting') { next.skippedForLaunch = false; @@ -12267,20 +12875,17 @@ export class TeamProvisioningService { return; } - if (prev.runtimeAlive === true && next.runtimeAlive !== true) { - this.taskActivityIntervalService.pauseActiveIntervalsForMember( - run.teamName, - memberName, - updatedAt - ); - } else if (prev.runtimeAlive !== true && next.runtimeAlive === true) { - this.taskActivityIntervalService.resumeActiveIntervalsForMember( - run.teamName, - memberName, - updatedAt - ); - } - + const runtimeTransitionAt = + status === 'online' && livenessSource === 'heartbeat' + ? (normalizeIsoTimestamp(heartbeatAt) ?? updatedAt) + : updatedAt; + this.syncMemberTaskActivityForRuntimeTransition( + run, + memberName, + prev, + next, + runtimeTransitionAt + ); run.memberSpawnStatuses.set(memberName, next); if ( (status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) || @@ -12387,6 +12992,14 @@ export class TeamProvisioningService { return; } + const runtimeTransitionAt = source === 'runtime-proof' ? observedAt : updatedAt; + this.syncMemberTaskActivityForRuntimeTransition( + run, + memberName, + prev, + next, + runtimeTransitionAt + ); run.memberSpawnStatuses.set(memberName, next); run.pendingMemberRestarts?.delete(memberName); this.syncMemberLaunchGraceCheck(run, memberName, next); @@ -12419,8 +13032,9 @@ export class TeamProvisioningService { source?: 'live' | 'persisted' | 'merged'; }> { const readPersistedStatuses = async (resolvedRunId: string | null) => { + const repairSnapshot = await this.readTaskActivityRepairLaunchSnapshot(teamName); + this.repairStaleTaskActivityIntervalsOnce(teamName, repairSnapshot); const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); - this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot); const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, { openCodeSecondaryBootstrapPendingMembers: this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot), @@ -13537,11 +14151,6 @@ export class TeamProvisioningService { throw new Error('Lead restart is not supported from member controls'); } - this.invalidateRuntimeSnapshotCaches(teamName); - this.resetRuntimeToolActivity(run, memberName); - this.clearMemberSpawnToolTracking(run, memberName); - this.setMemberSpawnStatus(run, memberName, 'spawning'); - this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); run.pendingMemberRestarts.set(memberName, { requestedAt: nowIso(), desired: { @@ -13554,6 +14163,11 @@ export class TeamProvisioningService { effort: configuredMember.effort, }, }); + this.invalidateRuntimeSnapshotCaches(teamName); + this.resetRuntimeToolActivity(run, memberName); + this.clearMemberSpawnToolTracking(run, memberName); + this.setMemberSpawnStatus(run, memberName, 'spawning'); + this.appendMemberBootstrapDiagnostic(run, memberName, 'manual restart requested from UI'); const leadName = this.resolveLeadMemberName( currentConfiguredMemberState.configuredMembers, @@ -14544,7 +15158,8 @@ export class TeamProvisioningService { if (restartPending) { run.pendingMemberRestarts.delete(memberName); } - run.memberSpawnStatuses.set(memberName, { + const livenessObservedAt = nowIso(); + const nextRuntimeLostStatus: MemberSpawnStatusEntry = { ...refreshed, runtimeAlive: false, livenessSource: undefined, @@ -14554,8 +15169,16 @@ export class TeamProvisioningService { ...(metadata?.runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity } : {}), - livenessLastCheckedAt: nowIso(), - }); + livenessLastCheckedAt: livenessObservedAt, + }; + this.syncMemberTaskActivityForRuntimeTransition( + run, + memberName, + refreshed, + nextRuntimeLostStatus, + livenessObservedAt + ); + run.memberSpawnStatuses.set(memberName, nextRuntimeLostStatus); this.setMemberSpawnStatus(run, memberName, 'error', strictReason); } @@ -14591,6 +15214,7 @@ export class TeamProvisioningService { updatedAt: observedAt, }; + this.syncMemberTaskActivityForRuntimeTransition(run, memberName, current, next, observedAt); run.memberSpawnStatuses.set(memberName, next); const launchDiagnostics = boundLaunchDiagnostics(buildLaunchDiagnosticsFromRun(run)); if (launchDiagnostics) { @@ -14688,6 +15312,7 @@ export class TeamProvisioningService { updatedAt: observedAt, }; + this.syncMemberTaskActivityForRuntimeTransition(run, memberName, current, next, observedAt); run.memberSpawnStatuses.set(memberName, next); const launchDiagnostics = boundLaunchDiagnostics(buildLaunchDiagnosticsFromRun(run)); if (launchDiagnostics) { @@ -16923,9 +17548,9 @@ export class TeamProvisioningService { if (existingProvisioningRunId) { return { runId: existingProvisioningRunId }; } - const previousLaunchSnapshot = await this.launchStateStore - .read(request.teamName) - .catch(() => null); + const previousLaunchSnapshot = await this.readTaskActivityRepairLaunchSnapshot( + request.teamName + ); this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot); const stopAllGenerationAtStart = this.stopAllTeamsGeneration; assertAppDeterministicBootstrapEnabled(); @@ -20622,6 +21247,10 @@ export class TeamProvisioningService { continue; } + if (run.pendingMemberRestarts?.has(expected) === true) { + continue; + } + const acceptedAtMs = current?.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; const graceExpired = @@ -20679,6 +21308,9 @@ export class TeamProvisioningService { if (this.isMemberLifecycleOperationActive(run.teamName, expected)) { continue; } + if (run.pendingMemberRestarts?.has(expected) === true) { + continue; + } const current = run.memberSpawnStatuses.get(expected); if ( @@ -20715,6 +21347,9 @@ export class TeamProvisioningService { if (this.isMemberLifecycleOperationActive(run.teamName, expected)) { continue; } + if (run.pendingMemberRestarts?.has(expected) === true) { + continue; + } const hasExistingTerminalFailure = prev.status === 'error' || prev.launchState === 'failed_to_start' || @@ -20752,6 +21387,7 @@ export class TeamProvisioningService { launchState: 'failed_to_start', }; + this.syncMemberTaskActivityForRuntimeTransition(run, expected, prev, next, failedAt); run.memberSpawnStatuses.set(expected, next); this.appendMemberBootstrapDiagnostic(run, expected, hardFailureReason); if (this.isCurrentTrackedRun(run)) { @@ -22479,12 +23115,62 @@ export class TeamProvisioningService { ): Record { const statuses: Record = {}; for (const expected of run.expectedMembers) { - statuses[expected] = + const current = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry(); + statuses[expected] = this.projectPendingRestartStatusForSnapshot(run, expected, current); } return statuses; } + private projectPendingRestartStatusForSnapshot( + run: ProvisioningRun, + memberName: string, + current: MemberSpawnStatusEntry + ): MemberSpawnStatusEntry { + const pendingRestart = run.pendingMemberRestarts?.get(memberName); + if (!pendingRestart) { + return current; + } + if ( + current.launchState === 'confirmed_alive' || + current.launchState === 'failed_to_start' || + current.launchState === 'skipped_for_launch' || + current.skippedForLaunch === true + ) { + return current; + } + + // A manual restart can be requested after the original launch has already finished. + // Persisting that transient state as `finished + starting` is unsafe because the + // launch-state evaluator intentionally converts old `starting` entries into + // "never spawned" failures. Project it as pending bootstrap until the lead accepts, + // rejects, or the normal restart grace timeout resolves it. + const updatedAt = current.updatedAt ?? pendingRestart.requestedAt; + const next: MemberSpawnStatusEntry = { + ...current, + status: 'waiting', + updatedAt, + skippedForLaunch: false, + skipReason: undefined, + skippedAt: undefined, + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + livenessSource: undefined, + bootstrapStalled: undefined, + runtimeDiagnostic: + current.runtimeDiagnostic ?? + 'Manual restart is already in progress; waiting for teammate bootstrap.', + runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity ?? 'info', + firstSpawnAcceptedAt: current.firstSpawnAcceptedAt ?? pendingRestart.requestedAt, + }; + next.launchState = deriveMemberLaunchState(next); + return next; + } + private async overlayPrimaryBootstrapTruthIntoRunStatusesFromBootstrapState( run: ProvisioningRun ): Promise { @@ -22572,6 +23258,7 @@ export class TeamProvisioningService { livenessLastCheckedAt: updatedAt, launchState: 'confirmed_alive', }; + this.syncMemberTaskActivityForRuntimeTransition(run, memberName, current, next, updatedAt); run.memberSpawnStatuses.set(memberName, next); run.pendingMemberRestarts?.delete(memberName); this.syncMemberLaunchGraceCheck(run, memberName, next); @@ -22858,8 +23545,16 @@ export class TeamProvisioningService { const snapshotStatuses = snapshotToMemberSpawnStatuses(snapshot); run.expectedMembers = memberNames; for (const memberName of memberNames) { + if (run.pendingMemberRestarts?.has(memberName) === true) { + continue; + } const entry = snapshotStatuses[memberName]; if (entry) { + const previous = + run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); + if (previous.runtimeAlive === true && entry.runtimeAlive !== true) { + this.pauseMemberTaskActivityForRuntimeLoss(run, memberName, previous, entry.updatedAt); + } run.memberSpawnStatuses.set(memberName, entry); } } @@ -26324,6 +27019,9 @@ export class TeamProvisioningService { */ async stopAllTeams(): Promise { this.stopAllTeamsGeneration += 1; + for (const teamName of this.getShutdownTrackedTeamNames()) { + this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName); + } killTrackedCliProcesses('SIGKILL'); this.killTransientProbeProcessesForShutdown(); diff --git a/src/main/services/team/TeamTaskActivityIntervalService.ts b/src/main/services/team/TeamTaskActivityIntervalService.ts index 0d1d1c90..00b8987b 100644 --- a/src/main/services/team/TeamTaskActivityIntervalService.ts +++ b/src/main/services/team/TeamTaskActivityIntervalService.ts @@ -15,6 +15,7 @@ import type { interface ActivityIntervalResult { changedTasks: number; + failed?: boolean; } type MutableTeamTask = TeamTask & { @@ -38,6 +39,14 @@ function toIso(ms: number): string { return new Date(ms).toISOString(); } +function isClosedInterval(interval: { completedAt?: unknown } | null | undefined): boolean { + return typeof interval?.completedAt === 'string' && parseIsoMs(interval.completedAt) > 0; +} + +function hasValidStartedAt(interval: { startedAt?: unknown } | null | undefined): boolean { + return typeof interval?.startedAt === 'string' && parseIsoMs(interval.startedAt) > 0; +} + function ensureCloseIso(startedAt: string, at: string): string { const startedAtMs = parseIsoMs(startedAt); const atMs = parseIsoMs(at); @@ -46,6 +55,32 @@ function ensureCloseIso(startedAt: string, at: string): string { return toIso(atMs); } +function resumeStartIso(activeStartedAt: string | null | undefined, at: string): string { + const activeStartedAtMs = parseIsoMs(activeStartedAt ?? undefined); + const atMs = parseIsoMs(at); + if (activeStartedAtMs > 0 && activeStartedAtMs > atMs) { + return toIso(activeStartedAtMs); + } + return atMs > 0 ? toIso(atMs) : toIso(Date.now()); +} + +function getStartedAtString(interval: { startedAt?: unknown } | null | undefined): string { + return typeof interval?.startedAt === 'string' ? interval.startedAt : ''; +} + +function hasUsableCompletedAt(interval: { completedAt?: unknown } | null | undefined): boolean { + return interval?.completedAt === undefined || isClosedInterval(interval); +} + +function pauseCloseIso( + interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined, + at: string +): string { + const startedAt = getStartedAtString(interval); + const closeAt = interval?.completedAt === undefined ? at : startedAt || at; + return ensureCloseIso(startedAt, closeAt); +} + function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string { const startedAtMs = parseIsoMs(startedAt); const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now(); @@ -62,10 +97,23 @@ function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemb return toIso(boundedCloseMs); } +function crashRepairIntervalCloseIso( + interval: { startedAt?: unknown; completedAt?: unknown } | null | undefined, + member?: PersistedTeamLaunchMemberState +): string { + const startedAt = getStartedAtString(interval); + if (interval?.completedAt === undefined) { + return crashRepairCloseIso(startedAt, member); + } + return ensureCloseIso(startedAt, startedAt || crashRepairCloseIso(startedAt, member)); +} + function hasOpenWorkInterval(task: MutableTeamTask): boolean { return ( Array.isArray(task.workIntervals) && - task.workIntervals.some((interval) => !interval.completedAt) + task.workIntervals.some( + (interval) => hasValidStartedAt(interval) && interval.completedAt === undefined + ) ); } @@ -74,7 +122,10 @@ function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean return ( Array.isArray(task.reviewIntervals) && task.reviewIntervals.some( - (interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey + (interval) => + hasValidStartedAt(interval) && + interval.completedAt === undefined && + normalizeMemberName(interval.reviewer) === reviewerKey ) ); } @@ -85,9 +136,9 @@ function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: strin let changed = false; task.workIntervals = task.workIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (isClosedInterval(interval)) return interval; changed = true; - return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) }; + return { ...interval, completedAt: pauseCloseIso(interval, at) }; }); return changed; } @@ -98,20 +149,45 @@ function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: let changed = false; task.reviewIntervals = task.reviewIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (isClosedInterval(interval)) return interval; if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval; changed = true; - return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) }; + return { ...interval, completedAt: pauseCloseIso(interval, at) }; }); return changed; } -function getActiveReviewActor(task: MutableTeamTask): string | null { +function getActiveWorkStartedAt(task: MutableTeamTask): string | null { + const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event.type === 'status_changed') { + if (event.to === 'in_progress') { + return parseIsoMs(event.timestamp) > 0 ? event.timestamp : null; + } + return null; + } + if (event.type === 'task_created') { + return event.status === 'in_progress' && parseIsoMs(event.timestamp) > 0 + ? event.timestamp + : null; + } + } + return null; +} + +function getActiveReviewStart( + task: MutableTeamTask +): { reviewer: string; startedAt: string } | null { const events = Array.isArray(task.historyEvents) ? task.historyEvents : []; for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index]; if (event.type === 'review_started') { - return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null; + const reviewer = + typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : ''; + return reviewer && parseIsoMs(event.timestamp) > 0 + ? { reviewer, startedAt: event.timestamp } + : null; } if ( event.type === 'review_approved' || @@ -126,6 +202,110 @@ function getActiveReviewActor(task: MutableTeamTask): string | null { return null; } +function hasWorkIntervalForStart(task: MutableTeamTask, startedAt: string): boolean { + const startedAtMs = parseIsoMs(startedAt); + return ( + startedAtMs > 0 && + Array.isArray(task.workIntervals) && + task.workIntervals.some((interval) => parseIsoMs(interval.startedAt) === startedAtMs) + ); +} + +function hasPersistedWorkIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean { + const startedAtMs = parseIsoMs(startedAt); + return ( + startedAtMs > 0 && + Array.isArray(task.workIntervals) && + task.workIntervals.some( + (interval) => + hasValidStartedAt(interval) && + hasUsableCompletedAt(interval) && + parseIsoMs(interval.startedAt) >= startedAtMs + ) + ); +} + +function hasReviewIntervalForStart( + task: MutableTeamTask, + reviewer: string, + startedAt: string +): boolean { + const reviewerKey = normalizeMemberName(reviewer); + const startedAtMs = parseIsoMs(startedAt); + return ( + reviewerKey.length > 0 && + startedAtMs > 0 && + Array.isArray(task.reviewIntervals) && + task.reviewIntervals.some( + (interval) => + normalizeMemberName(interval.reviewer) === reviewerKey && + parseIsoMs(interval.startedAt) === startedAtMs + ) + ); +} + +function hasPersistedReviewIntervalAtOrAfter(task: MutableTeamTask, startedAt: string): boolean { + const startedAtMs = parseIsoMs(startedAt); + return ( + startedAtMs > 0 && + Array.isArray(task.reviewIntervals) && + task.reviewIntervals.some( + (interval) => + normalizeMemberName(interval?.reviewer).length > 0 && + hasValidStartedAt(interval) && + hasUsableCompletedAt(interval) && + parseIsoMs(interval.startedAt) >= startedAtMs + ) + ); +} + +function materializePausedWorkInterval(task: MutableTeamTask, at: string, owner?: string): boolean { + if (task.status !== 'in_progress') return false; + if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false; + + const startedAt = getActiveWorkStartedAt(task); + if ( + !startedAt || + hasPersistedWorkIntervalAtOrAfter(task, startedAt) || + hasWorkIntervalForStart(task, startedAt) + ) { + return false; + } + task.workIntervals = [ + ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), + { startedAt, completedAt: ensureCloseIso(startedAt, at) }, + ]; + return true; +} + +function materializePausedReviewInterval( + task: MutableTeamTask, + at: string, + reviewer?: string +): boolean { + if (task.status !== 'completed') return false; + const activeReview = getActiveReviewStart(task); + if (!activeReview) return false; + if (reviewer && normalizeMemberName(activeReview.reviewer) !== normalizeMemberName(reviewer)) { + return false; + } + if ( + hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) || + hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt) + ) { + return false; + } + task.reviewIntervals = [ + ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), + { + reviewer: activeReview.reviewer, + startedAt: activeReview.startedAt, + completedAt: ensureCloseIso(activeReview.startedAt, at), + }, + ]; + return true; +} + function readTaskFile(filePath: string): MutableTeamTask | null { try { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown; @@ -155,7 +335,7 @@ export class TeamTaskActivityIntervalService { error instanceof Error ? error.message : String(error) }` ); - return { changedTasks: 0 }; + return { changedTasks: 0, failed: true }; } } @@ -167,8 +347,11 @@ export class TeamTaskActivityIntervalService { let entries: string[]; try { entries = fs.readdirSync(tasksDir); - } catch { - return { changedTasks: 0 }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { changedTasks: 0 }; + } + throw error; } let changedTasks = 0; @@ -195,7 +378,9 @@ export class TeamTaskActivityIntervalService { return this.mutateTeamTasks(teamName, (task) => { const changedWork = closeOpenWorkIntervals(task, at); const changedReview = closeOpenReviewIntervals(task, at); - return changedWork || changedReview; + const materializedWork = materializePausedWorkInterval(task, at); + const materializedReview = materializePausedReviewInterval(task, at); + return changedWork || changedReview || materializedWork || materializedReview; }); } @@ -207,7 +392,9 @@ export class TeamTaskActivityIntervalService { return this.mutateTeamTasks(teamName, (task) => { const changedWork = closeOpenWorkIntervals(task, at, memberName); const changedReview = closeOpenReviewIntervals(task, at, memberName); - return changedWork || changedReview; + const materializedWork = materializePausedWorkInterval(task, at, memberName); + const materializedReview = materializePausedReviewInterval(task, at, memberName); + return changedWork || changedReview || materializedWork || materializedReview; }); } @@ -227,23 +414,27 @@ export class TeamTaskActivityIntervalService { normalizeMemberName(task.owner) === memberKey && !hasOpenWorkInterval(task) ) { + const activeStartedAt = getActiveWorkStartedAt(task); task.workIntervals = [ ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), - { startedAt: at }, + { startedAt: resumeStartIso(activeStartedAt, at) }, ]; changed = true; } - const activeReviewer = getActiveReviewActor(task); + const activeReview = getActiveReviewStart(task); if ( task.status === 'completed' && - activeReviewer && - normalizeMemberName(activeReviewer) === memberKey && - !hasOpenReviewInterval(task, activeReviewer) + activeReview && + normalizeMemberName(activeReview.reviewer) === memberKey && + !hasOpenReviewInterval(task, activeReview.reviewer) ) { task.reviewIntervals = [ ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), - { reviewer: activeReviewer, startedAt: at }, + { + reviewer: activeReview.reviewer, + startedAt: resumeStartIso(activeReview.startedAt, at), + }, ]; changed = true; } @@ -266,23 +457,57 @@ export class TeamTaskActivityIntervalService { if (Array.isArray(task.workIntervals)) { const ownerMember = memberByName.get(normalizeMemberName(task.owner)); task.workIntervals = task.workIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (isClosedInterval(interval)) return interval; changed = true; - return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) }; + return { ...interval, completedAt: crashRepairIntervalCloseIso(interval, ownerMember) }; }); } + if (task.status === 'in_progress') { + const ownerMember = memberByName.get(normalizeMemberName(task.owner)); + const startedAt = getActiveWorkStartedAt(task); + if ( + startedAt && + !hasPersistedWorkIntervalAtOrAfter(task, startedAt) && + !hasWorkIntervalForStart(task, startedAt) + ) { + task.workIntervals = [ + ...(Array.isArray(task.workIntervals) ? task.workIntervals : []), + { startedAt, completedAt: crashRepairCloseIso(startedAt, ownerMember) }, + ]; + changed = true; + } + } if (Array.isArray(task.reviewIntervals)) { task.reviewIntervals = task.reviewIntervals.map((interval) => { - if (interval.completedAt) return interval; + if (isClosedInterval(interval)) return interval; const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer)); changed = true; return { ...interval, - completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember), + completedAt: crashRepairIntervalCloseIso(interval, reviewerMember), }; }); } + if (task.status === 'completed') { + const activeReview = getActiveReviewStart(task); + if ( + activeReview && + !hasPersistedReviewIntervalAtOrAfter(task, activeReview.startedAt) && + !hasReviewIntervalForStart(task, activeReview.reviewer, activeReview.startedAt) + ) { + const reviewerMember = memberByName.get(normalizeMemberName(activeReview.reviewer)); + task.reviewIntervals = [ + ...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []), + { + reviewer: activeReview.reviewer, + startedAt: activeReview.startedAt, + completedAt: crashRepairCloseIso(activeReview.startedAt, reviewerMember), + }, + ]; + changed = true; + } + } return changed; }); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index e537448d..3f9c0746 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -332,7 +332,7 @@ export class TeamTaskWriter { if (!wasInProgress && isInProgress) { // Entering in_progress: open a new interval if none is open. - if (!last || typeof last.completedAt === 'string') { + if (!last || last.completedAt !== undefined) { intervals.push({ startedAt: nowIso }); } } else if (wasInProgress && !isInProgress) { diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 373d3e9e..537919d3 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -68,7 +68,7 @@ const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000; // Longer than the renderer-facing UI timeout: late OpenCode turns should still // finish bridge-side observation and emit member-work-sync signals. const DEFAULT_SEND_TIMEOUT_MS = 45_000; -const DEFAULT_OBSERVE_TIMEOUT_MS = 8_000; +const DEFAULT_OBSERVE_TIMEOUT_MS = 20_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000; const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000; const DEFAULT_BACKFILL_TIMEOUT_MS = 45_000; diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 34d7037d..1706d190 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -389,7 +389,7 @@ export class OpenCodePromptDeliveryLedgerStore { visibleReplyCorrelation: input.visibleReplyCorrelation, lastReason: input.semanticallySufficient ? record.lastReason - : 'visible_reply_ack_only_still_requires_answer', + : selectOpenCodeDestinationProofInsufficientReason(input.diagnostics), diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []), updatedAt: input.observedAt, })); @@ -874,6 +874,22 @@ function shouldPruneOpenCodePromptDeliveryRecord( return false; } +function selectOpenCodeDestinationProofInsufficientReason( + diagnostics: readonly string[] | undefined +): string { + const normalizedDiagnostics = (diagnostics ?? []).map((diagnostic) => + diagnostic.trim().toLowerCase() + ); + if ( + normalizedDiagnostics.includes('visible_reply_missing_task_refs') || + normalizedDiagnostics.includes('visible_reply_missing_task_refs_after_merge') || + normalizedDiagnostics.includes('visible_reply_task_refs_merge_failed') + ) { + return 'visible_reply_missing_task_refs'; + } + return 'visible_reply_ack_only_still_requires_answer'; +} + function mergeDiagnostics(existing: string[], next: string[]): string[] { return [...new Set([...existing, ...next].filter((item) => item.trim()))]; } diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts index 8bea8b0f..a3676293 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryRepairPolicy.ts @@ -1,8 +1,5 @@ import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract'; -import type { - OpenCodePromptDeliveryLedgerRecord, - OpenCodePromptDeliveryStatus, -} from './OpenCodePromptDeliveryLedger'; +import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger'; import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team'; export type OpenCodePromptDeliveryRepairKind = @@ -128,12 +125,14 @@ function taskIdList(taskRefs: TaskRef[]): string | null { function messageSendControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { const replyRecipient = input.replyRecipient.trim() || 'user'; + const taskRefsJson = input.taskRefs.length > 0 ? JSON.stringify(input.taskRefs) : null; return [ 'The app still has no correlated visible reply proof for this message.', `Call agent-teams_message_send or mcp__agent-teams__message_send exactly once with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", and relayOfMessageId="${input.inboxMessageId}".`, + taskRefsJson ? `Include taskRefs exactly as this JSON array: ${taskRefsJson}.` : null, 'Use a concrete answer in text and summary. Do not reply only with acknowledgement.', 'After the message_send tool succeeds, stop this turn. Do not repeat task/tool work unless the inbound message explicitly asks for new work.', - ]; + ].filter((line): line is string => line !== null); } function workSyncControlLines(input: OpenCodePromptDeliveryRepairInput): string[] { @@ -169,7 +168,9 @@ function noAssistantControlLines(input: OpenCodePromptDeliveryRepairInput): stri ]; } -function toolErrorControl(input: OpenCodePromptDeliveryRepairInput) { +function toolErrorControl( + input: OpenCodePromptDeliveryRepairInput +): OpenCodePromptDeliveryRepairDecision { const tools = normalizedToolNames(input); if (hasTool(tools, 'message_send')) { return control( @@ -264,6 +265,7 @@ export function decideOpenCodePromptDeliveryRepair( if ( input.pendingReason === 'visible_reply_destination_not_found_yet' || input.pendingReason === 'visible_reply_missing_relayOfMessageId' || + input.pendingReason === 'visible_reply_missing_task_refs' || input.pendingReason === 'visible_reply_still_required' || (input.responseState === 'responded_visible_message' && !input.visibleReplyFound) ) { diff --git a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts index 8fc937bc..f97d5cb1 100644 --- a/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts +++ b/src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryDiagnostics.ts @@ -22,6 +22,10 @@ const GENERIC_DELIVERY_DIAGNOSTIC_TOKENS = [ 'visible_reply_ack_only_still_requires_answer', 'visible_reply_destination_not_found_yet', 'visible_reply_missing_relayofmessageid', + 'visible_reply_missing_task_refs', + 'visible_reply_missing_task_refs_after_merge', + 'visible_reply_task_refs_merge_failed', + 'opencode_runtime_delivery_task_refs_inherited_from_relay', 'non_visible_tool_without_task_progress', ] as const; @@ -101,9 +105,20 @@ function getOpenCodeRuntimeDeliveryStateFallback( ): string | null { const state = record.responseState?.trim(); const reason = record.lastReason?.trim(); + const diagnostics = record.diagnostics.map((diagnostic) => diagnostic.trim().toLowerCase()); if (state === 'empty_assistant_turn' || reason === 'empty_assistant_turn') { return 'OpenCode returned an empty assistant turn.'; } + if ( + reason === 'visible_reply_missing_task_refs' || + diagnostics.includes('visible_reply_missing_task_refs') || + diagnostics.includes('visible_reply_missing_task_refs_after_merge') + ) { + return 'OpenCode created a reply without the required taskRefs metadata.'; + } + if (diagnostics.includes('visible_reply_task_refs_merge_failed')) { + return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.'; + } if ( reason === 'visible_reply_still_required' || reason === 'visible_reply_ack_only_still_requires_answer' || diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 5bb59a6f..fbe9592a 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -929,6 +929,10 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) input.messageId ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` : null, + input.taskRefs?.length + ? `If taskRefs are present in , include taskRefs exactly as provided in that message_send call: ${JSON.stringify(input.taskRefs)}.` + : null, + 'If message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.', 'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.', 'You must not end this turn empty.', 'Do not answer only with plain assistant text when agent-teams_message_send is available.', diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index fe202870..f9cf2ef3 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -49,7 +49,7 @@ function getOpenWorkInterval(task: TeamTask): TaskWorkInterval | null { const intervals = task.workIntervals ?? []; for (let i = intervals.length - 1; i >= 0; i -= 1) { const interval = intervals[i]; - if (!interval.completedAt) { + if (interval.completedAt === undefined) { return interval; } } diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts index 27095494..79439153 100644 --- a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -99,11 +99,12 @@ function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): if (!Number.isFinite(startedAt) || time < startedAt) { return false; } - if (!interval.completedAt) { + if (interval.completedAt === undefined) { return true; } const completedAt = Date.parse(interval.completedAt); - return !Number.isFinite(completedAt) || time <= completedAt; + const endMs = Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt; + return time <= endMs; }); } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index b7f973dc..7c180fea 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1285,9 +1285,14 @@ function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeW } const completedAt = typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + const endMs = + interval.completedAt === undefined + ? null + : (Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt) + + INFERRED_WINDOW_GRACE_AFTER_MS; return { startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, - endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + endMs, }; }) .filter((window): window is TimeWindow => window !== null); diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index 093f9f2e..b44bc2bc 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -831,9 +831,14 @@ function buildTaskTimeWindows(task: TeamTask): TimeWindow[] { } const completedAt = typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + const endMs = + interval.completedAt === undefined + ? null + : (Number.isFinite(completedAt) ? Math.max(completedAt, startedAt) : startedAt) + + WINDOW_GRACE_AFTER_MS; return { startMs: startedAt - WINDOW_GRACE_BEFORE_MS, - endMs: Number.isFinite(completedAt) ? completedAt + WINDOW_GRACE_AFTER_MS : null, + endMs, }; }) .filter((window): window is TimeWindow => window !== null); diff --git a/src/main/services/team/teamTaskActiveState.ts b/src/main/services/team/teamTaskActiveState.ts index f58abf2f..57fb67f4 100644 --- a/src/main/services/team/teamTaskActiveState.ts +++ b/src/main/services/team/teamTaskActiveState.ts @@ -21,7 +21,7 @@ function getActiveWorkStartedAt(task: TeamTaskWithKanban): number { const workIntervals = task.workIntervals ?? []; for (let index = workIntervals.length - 1; index >= 0; index--) { const interval = workIntervals[index]; - if (interval && !interval.completedAt) { + if (interval && interval.completedAt === undefined) { const startedAt = parseIsoTime(interval.startedAt); if (startedAt > 0) { return startedAt; diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 60a31358..30e1ebdf 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -9,6 +9,7 @@ import { createEmptyMemberLogPreviewResponse, createEmptyMemberLogStreamResponse, + createEmptyMemberRuntimeLogTailResponse, } from '@features/member-log-stream/contracts'; import type { @@ -271,6 +272,10 @@ export class HttpAPIClient implements ElectronAPI { console.warn('[HttpAPIClient] getMemberLogPreviews is not available in browser mode'); return createEmptyMemberLogPreviewResponse(); }, + getMemberRuntimeLogTail: async (_teamName, _memberName, options) => { + console.warn('[HttpAPIClient] getMemberRuntimeLogTail is not available in browser mode'); + return createEmptyMemberRuntimeLogTailResponse(options.kind); + }, setMemberLogStreamTracking: async () => { // Not available in browser mode - no-op. }, diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 6ecdd984..32a21a95 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -36,25 +36,47 @@ import type { MemberLogSummary } from '@shared/types'; const CHUNK_GRACE_BEFORE_MS = 30_000; // 30s before startedAt const CHUNK_GRACE_AFTER_MS = 10_000; // 10s after completedAt -function filterChunksByWorkIntervals( +function getWorkIntervalWindow( + interval: { startedAt: string; completedAt?: string }, + options: { + graceBeforeMs: number; + graceAfterMs: number; + nowMs: number; + } +): { startMs: number; endMs: number } | null { + const startMs = Date.parse(interval.startedAt); + if (!Number.isFinite(startMs)) return null; + if (interval.completedAt === undefined) { + return { + startMs: startMs - options.graceBeforeMs, + endMs: options.nowMs + options.graceAfterMs, + }; + } + const completedAtMs = Date.parse(interval.completedAt); + const endMs = Number.isFinite(completedAtMs) ? Math.max(completedAtMs, startMs) : startMs; + return { + startMs: startMs - options.graceBeforeMs, + endMs: endMs + options.graceAfterMs, + }; +} + +export function filterChunksByWorkIntervals( chunks: EnhancedChunk[] | null, intervals: { startedAt: string; completedAt?: string }[] | undefined ): EnhancedChunk[] | null { if (!chunks) return null; if (!intervals || intervals.length === 0) return chunks; - const now = Date.now(); + const nowMs = Date.now(); const parsed = intervals - .map((i) => { - const s = Date.parse(i.startedAt); - if (!Number.isFinite(s)) return null; - const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null; - return { - startMs: s - CHUNK_GRACE_BEFORE_MS, - endMs: e != null && Number.isFinite(e) ? e + CHUNK_GRACE_AFTER_MS : null, - }; - }) - .filter((v): v is { startMs: number; endMs: number | null } => v !== null); + .map((interval) => + getWorkIntervalWindow(interval, { + graceBeforeMs: CHUNK_GRACE_BEFORE_MS, + graceAfterMs: CHUNK_GRACE_AFTER_MS, + nowMs, + }) + ) + .filter((v): v is { startMs: number; endMs: number } => v !== null); if (parsed.length === 0) return chunks; @@ -62,10 +84,7 @@ function filterChunksByWorkIntervals( const cs = chunk.startTime.getTime(); const ce = chunk.endTime.getTime(); if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true; - return parsed.some((i) => { - const end = i.endMs ?? now; - return cs <= end && ce >= i.startMs; - }); + return parsed.some((i) => cs <= i.endMs && ce >= i.startMs); }); return filtered; } @@ -215,13 +234,14 @@ export const MemberLogsTab = ({ let totalOverlap = 0; for (const interval of taskWorkIntervals) { - const intStart = Date.parse(interval.startedAt); - if (!Number.isFinite(intStart)) continue; - const intEnd = - typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : nowMs; - if (!Number.isFinite(intEnd)) continue; - const overlapStart = Math.max(logStartMs, intStart); - const overlapEnd = Math.min(logEndMs, intEnd); + const window = getWorkIntervalWindow(interval, { + graceBeforeMs: 0, + graceAfterMs: 0, + nowMs, + }); + if (!window) continue; + const overlapStart = Math.max(logStartMs, window.startMs); + const overlapEnd = Math.min(logEndMs, window.endMs); if (overlapEnd > overlapStart) totalOverlap += overlapEnd - overlapStart; } return totalOverlap; @@ -294,17 +314,15 @@ export const MemberLogsTab = ({ ) { const GRACE_BEFORE = 30_000; const GRACE_AFTER = 15_000; - const now = Date.now(); + const nowMs = Date.now(); const intervals = taskWorkIntervals - .map((i) => { - const s = Date.parse(i.startedAt); - if (!Number.isFinite(s)) return null; - const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null; - return { - startMs: s - GRACE_BEFORE, - endMs: e != null && Number.isFinite(e) ? e + GRACE_AFTER : now + GRACE_AFTER, - }; - }) + .map((interval) => + getWorkIntervalWindow(interval, { + graceBeforeMs: GRACE_BEFORE, + graceAfterMs: GRACE_AFTER, + nowMs, + }) + ) .filter((v): v is { startMs: number; endMs: number } => v !== null); if (intervals.length > 0) { diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index f96031c3..4e6790c3 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -664,7 +664,7 @@ export const MessageComposer = ({ className={cn( 'mr-[15px] inline-flex items-center border text-xs transition-colors', shouldDockRecipientSelector - ? 'relative z-10 -mb-px overflow-hidden rounded-b-none rounded-t-[1.35rem] border-b-0 bg-[var(--color-surface-raised)]' + ? 'relative z-[1] -mb-px overflow-hidden rounded-b-none rounded-t-[1.35rem] border-b-0 bg-[var(--color-surface-raised)]' : 'rounded-full', isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]' )} @@ -948,7 +948,7 @@ export const MessageComposer = ({ ) : null} -
+
= 0; index -= 1) { const interval = intervals[index]; const startedAtMs = parseIsoMs(interval?.startedAt); - if (startedAtMs > 0 && !interval?.completedAt) { + if (startedAtMs > 0 && interval?.completedAt === undefined) { for (let previousIndex = 0; previousIndex < index; previousIndex += 1) { const previous = intervals[previousIndex]; const previousStartedAtMs = parseIsoMs(previous?.startedAt); @@ -335,7 +335,10 @@ export function deriveReviewActivityTimerAnchor( const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []; for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) { const interval = reviewIntervals[index]; - if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) { + if ( + normalizeMemberName(interval?.reviewer) !== memberKey || + interval?.completedAt !== undefined + ) { continue; } const startedAtMs = parseIsoMs(interval.startedAt); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index d078bf0c..b0a9fda2 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -362,6 +362,15 @@ function formatRuntimeAdvisoryDisplayMessage(message: string | undefined): strin ) { return 'OpenCode created a reply without the required relayOfMessageId correlation.'; } + if (trimmed === 'visible_reply_missing_task_refs') { + return 'OpenCode created a reply without the required taskRefs metadata.'; + } + if (trimmed === 'visible_reply_missing_task_refs_after_merge') { + return 'OpenCode created a reply without the required taskRefs metadata.'; + } + if (trimmed === 'visible_reply_task_refs_merge_failed') { + return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.'; + } if (trimmed === 'non_visible_tool_without_task_progress') { return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; } diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 1482a527..166840ad 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -46,6 +46,15 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde ) { return 'OpenCode created a reply without the required relayOfMessageId correlation.'; } + if (normalized === 'visible_reply_missing_task_refs') { + return 'OpenCode created a reply without the required taskRefs metadata.'; + } + if (normalized === 'visible_reply_missing_task_refs_after_merge') { + return 'OpenCode created a reply without the required taskRefs metadata.'; + } + if (normalized === 'visible_reply_task_refs_merge_failed') { + return 'OpenCode created a reply without the required taskRefs metadata, and the app could not attach it automatically.'; + } if (normalized === 'non_visible_tool_without_task_progress') { return 'OpenCode used tools, but did not create a visible reply or task progress proof.'; } diff --git a/src/shared/utils/taskWorkDuration.ts b/src/shared/utils/taskWorkDuration.ts index 5e370ca7..7202889f 100644 --- a/src/shared/utils/taskWorkDuration.ts +++ b/src/shared/utils/taskWorkDuration.ts @@ -63,7 +63,7 @@ export function calculateTaskImplementationDuration startMs) { + if (interval?.completedAt === undefined && task.status === 'in_progress' && nowMs > startMs) { windows.push({ startMs, endMs: nowMs }); hasRunningInterval = true; } @@ -130,7 +130,12 @@ export function calculateTaskImplementationEventDuration< for (const interval of task.workIntervals) { const startMs = parseIsoMs(interval?.startedAt); - if (startMs > 0 && !interval?.completedAt && nowMs > startMs && isNearTime(startMs, eventMs)) { + if ( + startMs > 0 && + interval?.completedAt === undefined && + nowMs > startMs && + isNearTime(startMs, eventMs) + ) { return { elapsedMs: nowMs - startMs, running: true }; } } diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json index c4be27d9..a73955ca 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-05-08T18:34:37.950Z", + "generatedAt": "2026-05-08T21:13:58.089Z", "runsPerModel": 1, "qualification": { "minimumAverageScore": 80, @@ -10,23 +10,23 @@ "models": [ { "model": "opencode/big-pickle", - "verdict": "tested-only", - "confidence": "low", + "verdict": "infra-blocked", + "confidence": "blocked", "qualified": false, - "readinessScore": 73, - "averageScore": 90, - "consistencyScore": 100, - "behavioralAverageScore": 90, - "minScore": 90, + "readinessScore": 0, + "averageScore": 70, + "consistencyScore": 0, + "behavioralAverageScore": null, + "minScore": 70, "successfulRuns": 0, - "countedRuns": 1, - "hardFailures": 1, - "providerInfraFailures": 0, + "countedRuns": 0, + "hardFailures": 0, + "providerInfraFailures": 1, "runtimeTransportFailures": 0, - "modelBehaviorFailures": 1, + "modelBehaviorFailures": 0, "harnessFailures": 0, - "p50DurationMs": 124249, - "p95DurationMs": 124249, + "p50DurationMs": 281016, + "p95DurationMs": 281016, "stagePassRates": { "launchBootstrap": { "passed": 1, @@ -49,9 +49,9 @@ "rate": 100 }, "concurrentReplies": { - "passed": 1, + "passed": 0, "total": 1, - "rate": 100 + "rate": 0 }, "taskRefs": { "passed": 0, @@ -64,9 +64,9 @@ "rate": 100 }, "noDuplicateTokens": { - "passed": 1, + "passed": 0, "total": 1, - "rate": 100 + "rate": 0 }, "latencyStable": { "passed": 1, @@ -103,10 +103,20 @@ }, "protocolViolationTotals": { "badMessages": 0, - "duplicateOrMissingTokens": 0, - "affectedRuns": 0 + "duplicateOrMissingTokens": 2, + "affectedRuns": 1 }, "stageFailureImpact": [ + { + "stage": "concurrentReplies", + "failedRuns": 1, + "weightedLoss": 15, + "passRate": { + "passed": 0, + "total": 1, + "rate": 0 + } + }, { "stage": "taskRefs", "failedRuns": 1, @@ -118,17 +128,17 @@ } }, { - "stage": "cleanTranscript", - "failedRuns": 0, - "weightedLoss": 0, + "stage": "noDuplicateTokens", + "failedRuns": 1, + "weightedLoss": 5, "passRate": { - "passed": 1, + "passed": 0, "total": 1, - "rate": 100 + "rate": 0 } }, { - "stage": "concurrentReplies", + "stage": "cleanTranscript", "failedRuns": 0, "weightedLoss": 0, "passRate": { @@ -167,16 +177,6 @@ "rate": 100 } }, - { - "stage": "noDuplicateTokens", - "failedRuns": 0, - "weightedLoss": 0, - "passRate": { - "passed": 1, - "total": 1, - "rate": 100 - } - }, { "stage": "peerRelayAB", "failedRuns": 0, @@ -199,43 +199,48 @@ } ], "scoreStability": { - "sampleSize": 1, - "minScore": 90, - "maxScore": 90, + "sampleSize": 0, + "minScore": 0, + "maxScore": 0, "spread": 0, "standardDeviation": 0, - "consistencyScore": 100 + "consistencyScore": 0 }, - "dominantFailureCategory": "model-behavior", + "dominantFailureCategory": "provider-infra", "recommendationBlockers": [ + "overall average 70 < 80", "successful runs 0 < 1", - "hard failures 1", - "model-behavior failures 1", - "highest weighted stage loss taskRefs=10", - "weakest taskRefs concurrentBob=0/1 (0%)" + "consistency score 0 < 85", + "provider-infra failures 1", + "highest weighted stage loss concurrentReplies=15", + "weakest taskRefs concurrentBob=0/1 (0%)", + "protocol violations in 1 runs" ], "runs": [ { "runIndex": 1, "passed": false, - "score": 90, - "countedForRecommendation": true, - "outcome": "behavioral-fail", - "failureCategory": "model-behavior", - "primaryFailure": null, - "durationMs": 124249, - "hardFailure": true, + "score": 70, + "countedForRecommendation": false, + "outcome": "provider-infra-blocked", + "failureCategory": "provider-infra", + "primaryFailure": "concurrentBob: Timed out waiting for OpenCode reply in /var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/opencode-semantic-gauntlet-ZwZPyq/.claude/teams/opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1/inboxes/user.js", + "durationMs": 281016, + "hardFailure": false, "stageDurationsMs": { - "setup": 214, - "launchBootstrap": 23875, - "materializeTasks": 32, - "directReply": 11617, - "peerRelayAB": 27950, - "peerRelayBC": 25689, - "concurrentReplies": 25243, - "hygiene": 1 + "setup": 191, + "launchBootstrap": 23292, + "materializeTasks": 35, + "directReply": 11824, + "peerRelayAB": 23278, + "peerRelayBC": 24264, + "concurrentReplies": 189928, + "hygiene": 7 + }, + "stageFailures": { + "concurrentBob": "Timed out waiting for OpenCode reply in /var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/opencode-semantic-gauntlet-ZwZPyq/.claude/teams/opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1/inboxes/user.json. Last messages: [ { \"from\": \"bob\", \"to\": \"user\", \"text\": \"GAUNTLET_DIRECT_BOB_OK_1\", \"timestamp\": \"2026-05-08T21:14:31.960Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-59560c95-runtime-delivery\", \"displayId\": \"59560c95\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Direct reply to user for task #59560c95\", \"relayOfMessageId\": \"gauntlet-direct-1-1778274861608\", \"source\": \"runtime_delivery\", \"messageId\": \"e4c41d01-6709-4778-9308-2ba0c7204863\" }, { \"from\": \"jack\", \"to\": \"user\", \"text\": \"GAUNTLET_JACK_USER_OK_1\", \"timestamp\": \"2026-05-08T21:14:56.347Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"GAUNTLET_JACK_USER_OK_1\", \"relayOfMessageId\": \"80ad7c43-8b97-4a10-b145-8904019f6a8d\", \"source\": \"runtime_delivery\", \"messageId\": \"4c0c1175-96a2-40cc-8d6b-903bab01d715\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_TOM_USER_OK_1\", \"timestamp\": \"2026-05-08T21:15:23.513Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-82ad912c-multihop-relay\", \"displayId\": \"82ad912c\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Reply to user with gauntlet confirmation\", \"relayOfMessageId\": \"54896d68-e7f3-4b46-9716-f4c02cc5a08f\", \"source\": \"runtime_delivery\", \"messageId\": \"662edc80-bdb6-4295-8f37-1f99d85d388e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_2\", \"timestamp\": \"2026-05-08T21:15:44.993Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 2 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-2-1778274949725\", \"source\": \"runtime_delivery\", \"messageId\": \"61eab9d5-5e5e-4a9a-bcd1-5d12f3a6bd16\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_3\", \"timestamp\": \"2026-05-08T21:15:52.233Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 3 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-3-1778275302871\", \"source\": \"runtime_delivery\", \"messageId\": \"f00aa7e3-ef85-4353-8863-f87def1bc092\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_5\", \"timestamp\": \"2026-05-08T21:15:58.217Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 5 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-5-1778275307923\", \"source\": \"runtime_delivery\", \"messageId\": \"d499a34b-4bc4-4aab-ba8b-07c413182f19\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_7\", \"timestamp\": \"2026-05-08T21:16:04.658Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 7 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-7-1778275344699\", \"source\": \"runtime_delivery\", \"messageId\": \"e5ecee41-0a1a-4e15-870a-8838ffc7cdaf\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_9\", \"timestamp\": \"2026-05-08T21:16:09.443Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 9 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-9-1778275370537\", \"source\": \"runtime_delivery\", \"messageId\": \"5b4287bc-3464-472e-8c60-52d8aef631df\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_12\", \"timestamp\": \"2026-05-08T21:16:19.666Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 12 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-12-1778275389270\", \"source\": \"runtime_delivery\", \"messageId\": \"a8ea38f9-18e9-45b2-9fd4-5eeb78235c96\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_14\", \"timestamp\": \"2026-05-08T21:16:27.054Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 14 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-14-1778275399971\", \"source\": \"runtime_delivery\", \"messageId\": \"d97a37f9-c05d-4d18-8b57-3374b59fa0d2\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_16\", \"timestamp\": \"2026-05-08T21:16:33.853Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 16 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-16-1778275413442\", \"source\": \"runtime_delivery\", \"messageId\": \"c3a7190d-c1ae-4888-9c5b-88b19e5c7bac\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_18\", \"timestamp\": \"2026-05-08T21:16:38.928Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 18 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-18-1778275419904\", \"source\": \"runtime_delivery\", \"messageId\": \"60b91d11-501d-45e0-b8bb-36e6eee615af\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_21\", \"timestamp\": \"2026-05-08T21:16:43.568Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 21 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-21-1778275428583\", \"source\": \"runtime_delivery\", \"messageId\": \"0518ac52-2ffb-4d37-9e80-f8e54fe0968b\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_23\", \"timestamp\": \"2026-05-08T21:16:48.096Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 23 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-23-1778275432194\", \"source\": \"runtime_delivery\", \"messageId\": \"bee1817b-db32-4f37-9f5a-346f1d7c4c44\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_25\", \"timestamp\": \"2026-05-08T21:16:52.879Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 25 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-25-1778275435639\", \"source\": \"runtime_delivery\", \"messageId\": \"67315618-e854-4177-87b8-ea99b5222509\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_26\", \"timestamp\": \"2026-05-08T21:16:57.850Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 26 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-26-1778275438008\", \"source\": \"runtime_delivery\", \"messageId\": \"8055cecc-e72c-4649-a798-da6d8c222db7\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_27\", \"timestamp\": \"2026-05-08T21:17:02.500Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 27 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-27-1778275439877\", \"source\": \"runtime_delivery\", \"messageId\": \"51b48d44-db24-4845-ab3f-cedfdf002df8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_29\", \"timestamp\": \"2026-05-08T21:17:09.924Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 29 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-29-1778275445843\", \"source\": \"runtime_delivery\", \"messageId\": \"f508e8a7-a3de-4308-96ed-08b8e570cabe\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_31\", \"timestamp\": \"2026-05-08T21:17:15.243Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 31 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-31-1778275451544\", \"source\": \"runtime_delivery\", \"messageId\": \"13fe95b3-d9e2-44da-904e-17d7be66bebb\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_BREAK\", \"timestamp\": \"2026-05-08T21:17:29.306Z\", \"read\": false, \"summary\": \"Final concurrent break acknowledgment\", \"relayOfMessageId\": \"gauntlet-concurrent-break-tom-1778275525901\", \"source\": \"runtime_delivery\", \"messageId\": \"d3a24aee-79c5-4a72-a363-7ce82882f00b\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_2\", \"timestamp\": \"2026-05-08T21:17:39.271Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 2\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-2-1778275562721\", \"source\": \"runtime_delivery\", \"messageId\": \"ee865075-7ec8-4dd7-92f2-d5b58c5a184e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_3\", \"timestamp\": \"2026-05-08T21:17:43.438Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 3\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-3-1778275572730\", \"source\": \"runtime_delivery\", \"messageId\": \"1ed75ea3-56c1-4a7d-a51d-c6bac8e0ee84\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_4\", \"timestamp\": \"2026-05-08T21:17:47.202Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 4\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-4-1778275576703\", \"source\": \"runtime_delivery\", \"messageId\": \"3969eb02-5bf5-4e34-9ccb-fc4ff2ad027f\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_5\", \"timestamp\": \"2026-05-08T21:17:51.065Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 5\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-5-1778275580427\", \"source\": \"runtime_delivery\", \"messageId\": \"4e1b0ffb-af3e-4618-a40b-7f876e14ab46\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_6\", \"timestamp\": \"2026-05-08T21:17:54.649Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 6\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-6-1778275584095\", \"source\": \"runtime_delivery\", \"messageId\": \"2a5445d7-0b5e-4534-981f-4d2d9309bed8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_7\", \"timestamp\": \"2026-05-08T21:17:59.138Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 7\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-7-1778275587643\", \"source\": \"runtime_delivery\", \"messageId\": \"741e7465-c8cc-45cb-b3b4-6b131561e1ed\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_8\", \"timestamp\": \"2026-05-08T21:18:02.903Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 8\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-8-1778275596297\", \"source\": \"runtime_delivery\", \"messageId\": \"c41285fb-4564-46cb-ac49-aef1c04a9bde\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_9\", \"timestamp\": \"2026-05-08T21:18:06.749Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 9\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-9-1778275602630\", \"source\": \"runtime_delivery\", \"messageId\": \"cf46f18e-1d1c-49d2-b4b7-26c7f8464df8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_10\", \"timestamp\": \"2026-05-08T21:18:10.921Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 10\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-10-1778275606304\", \"source\": \"runtime_delivery\", \"messageId\": \"5a3c8614-e594-4a16-866b-943096b3c73e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_11\", \"timestamp\": \"2026-05-08T21:18:14.989Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 11\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-11-1778275609988\", \"source\": \"runtime_delivery\", \"messageId\": \"70d8715b-7984-49d2-a526-f1109609cee8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_12\", \"timestamp\": \"2026-05-08T21:18:18.757Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 12\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-12-1778275613650\", \"source\": \"runtime_delivery\", \"messageId\": \"ca4f9ccc-4abc-4793-a006-6d9d107b3e8a\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_13\", \"timestamp\": \"2026-05-08T21:18:22.476Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 13\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-13-1778275617618\", \"source\": \"runtime_delivery\", \"messageId\": \"10c7ac23-f1e1-4450-a73e-21af460481a9\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_14\", \"timestamp\": \"2026-05-08T21:18:26.505Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 14\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-14-1778275622290\", \"source\": \"runtime_delivery\", \"messageId\": \"5e255ff3-6be9-4ae2-b9df-800846dc7101\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_15\", \"timestamp\": \"2026-05-08T21:18:30.313Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 15\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-15-1778275625859\", \"source\": \"runtime_delivery\", \"messageId\": \"3750cace-2c71-46bf-877a-2a9446b49451\" } ] Transcript: { \"ok\": true, \"schemaVersion\": 1, \"requestId\": \"opencode-bridge-74789383-205c-442c-bb52-885fdffdb00b\", \"command\": \"opencode.getRuntimeTranscript\", \"completedAt\": \"2026-05-08T21:18:39.091Z\", \"durationMs\": 1965, \"runtime\": { \"providerId\": \"opencode\", \"binaryPath\": \"opencode\", \"binaryFingerprint\": \"version:1.14.19\", \"version\": \"1.14.19\", \"capabilitySnapshotId\": \"opencode:8295cf1215cc187732f1b9168503622b\" }, \"diagnostics\": [], \"data\": { \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"durableState\": \"reply_pending\", \"messages\": [ { \"id\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"parentId\": null, \"role\": \"user\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274879442, \"completedAt\": null, \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\", \"reasoningText\": \"\", \"previewText\": \" You are an OpenCode teammate managed by the desktop app. Runtime identity reminder for this delivered app message. This session i...\", \"partTypes\": [ \"text\" ], \"reasoningPartCount\": 0, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\" } ], \"finishReason\": null, \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"parentId\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"role\": \"assistant\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274879446, \"completedAt\": 1778274887054, \"text\": \"\", \"reasoningText\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\", \"previewText\": null, \"partTypes\": [ \"step-start\", \"reasoning\", \"tool\", \"step-finish\" ], \"reasoningPartCount\": 1, \"toolCalls\": [ { \"callId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"toolName\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"output\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"outputText\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"outputPreview\": \"{ \\\"deliveredToInbox\\\": true, \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\", \\\"message\\\": { \\\"from\\\": \\\"bob\\\", \\\"to\\\": \\\"jack\\\", \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK...\", \"startedAt\": 1778274886921, \"completedAt\": 1778274886952, \"isError\": false } ], \"contentBlocks\": [ { \"type\": \"step_start\" }, { \"type\": \"reasoning\", \"text\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\" }, { \"type\": \"tool_use\", \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"startedAt\": 1778274886921, \"completedAt\": 1778274886952 }, { \"type\": \"tool_result\", \"toolUseId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"toolName\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"contentText\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"contentPreview\": \"{ \\\"deliveredToInbox\\\": true, \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\", \\\"message\\\": { \\\"from\\\": \\\"bob\\\", \\\"to\\\": \\\"jack\\\", \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK...\", \"rawContent\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"isError\": false, \"startedAt\": 1778274886921, \"completedAt\": 1778274886952 }, { \"type\": \"step_finish\", \"reason\": \"tool-calls\" } ], \"finishReason\": \"tool-calls\", \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_e09716590001lO20OvwnQOa4do\", \"parentId\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"role\": \"assistant\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274887056, \"completedAt\": 1778274889015, \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\", \"reasoningText\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\", \"previewText\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\", \"partTypes\": [ \"step-start\", \"reasoning\", \"text\", \"step-finish\" ], \"reasoningPartCount\": 1, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"step_start\" }, { \"type\": \"reasoning\", \"text\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\" }, { \"type\": \"text\", \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\" }, { \"type\": \"step_finish\", \"reason\": \"stop\" } ], \"finishReason\": \"stop\", \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_5228a61df8294358b8feb5aceec27d2c\", \"parentId\": null, \"role\": \"user\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274933336, \"completedAt\": null, \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\", \"reasoningText\": \"\", \"previewText\": \" You are an OpenCode teammate managed by the desktop app. Runtime identity reminder for this delivered app message. This session i...\", \"partTypes\": [ \"text\" ], \"reasoningPartCount\": 0, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\" } ], \"finishReason\": null, \"errorName\": null, \"errorMessage\": null, \"hasError\": false } ], \"logProjection\": { \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"durableState\": \"reply_pending\", \"sourceMessageCount\": 4, \"projectedMessageCount\": 5, \"syntheticMessageCount\": 1, \"toolCallCount\": 1, \"errorCount\": 0, \"diagnostics\": [ \"OpenCode session is stale (managed_auth_changed); reading historical messages for log projection only\", \"OpenCode session reconcile skipped because the stored session is stale (managed_auth_changed)\" ], \"messages\": [ { \"uuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"parentUuid\": null, \"type\": \"user\", \"timestamp\": \"2026-05-08T21:14:39.442Z\", \"role\": \"user\", \"content\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] }, { \"uuid\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"parentUuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"type\": \"assistant\", \"timestamp\": \"2026-05-08T21:14:39.446Z\", \"role\": \"assistant\", \"content\": [ { \"type\": \"thinking\", \"thinking\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\", \"signature\": \"opencode\" }, { \"type\": \"tool_use\", \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] } } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [ { \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"isTask\": false } ], \"toolResults\": [] }, { \"uuid\": \"msg_e097147d6001BiPpgEgwLzYkib::tool_results\", \"parentUuid\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"type\": \"user\", \"timestamp\": \"2026-05-08T21:14:47.054Z\", \"role\": \"user\", \"content\": [ { \"type\": \"tool_result\", \"tool_use_id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"content\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": true, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [ { \"toolUseId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"content\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"isError\": false } ], \"sourceToolUseID\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"sourceToolAssistantUUID\": \"msg_e097147d6001BiPpgEgwLzYkib\" }, { \"uuid\": \"msg_e09716590001lO20OvwnQOa4do\", \"parentUuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"type\": \"assistant\", \"timestamp\": \"2026-05-08T21:14:47.056Z\", \"role\": \"assistant\", \"content\": [ { \"type\": \"thinking\", \"thinking\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\", \"signature\": \"opencode\" }, { \"type\": \"text\", \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] }, { \"uuid\": \"msg_5228a61df8294358b8feb5aceec27d2c\", \"parentUuid\": null, \"type\": \"user\", \"timestamp\": \"2026-05-08T21:15:33.336Z\", \"role\": \"user\", \"content\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] } ] }, \"diagnostics\": [ \"OpenCode session is stale (managed_auth_changed); reading historical messages for log projection only\", \"OpenCode session reconcile skipped because the stored session is stale (managed_auth_changed)\" ] } }", + "concurrentReplies": "one_or_more_concurrent_deliveries_failed" }, - "stageFailures": {}, "taskRefChecks": { "directReply": true, "peerRelayAB": true, @@ -245,21 +250,26 @@ }, "protocolViolations": { "badMessages": 0, - "duplicateOrMissingTokens": [] + "duplicateOrMissingTokens": [ + "GAUNTLET_CONCURRENT_BOB_OK_1", + "GAUNTLET_CONCURRENT_TOM_OK_1" + ] }, "stages": { "launchBootstrap": true, "directReply": true, "peerRelayAB": true, "peerRelayBC": true, - "concurrentReplies": true, + "concurrentReplies": false, "taskRefs": false, "cleanTranscript": true, - "noDuplicateTokens": true, + "noDuplicateTokens": false, "latencyStable": true }, "diagnostics": [ - "runId=34e07fb0-df87-4419-be0c-0f5386847b23" + "runId=375a5319-7775-479c-a79b-3bfb1392c555", + "concurrentBob: Timed out waiting for OpenCode reply in /var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/opencode-semantic-gauntlet-ZwZPyq/.claude/teams/opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1/inboxes/user.json. Last messages: [ { \"from\": \"bob\", \"to\": \"user\", \"text\": \"GAUNTLET_DIRECT_BOB_OK_1\", \"timestamp\": \"2026-05-08T21:14:31.960Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-59560c95-runtime-delivery\", \"displayId\": \"59560c95\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Direct reply to user for task #59560c95\", \"relayOfMessageId\": \"gauntlet-direct-1-1778274861608\", \"source\": \"runtime_delivery\", \"messageId\": \"e4c41d01-6709-4778-9308-2ba0c7204863\" }, { \"from\": \"jack\", \"to\": \"user\", \"text\": \"GAUNTLET_JACK_USER_OK_1\", \"timestamp\": \"2026-05-08T21:14:56.347Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"GAUNTLET_JACK_USER_OK_1\", \"relayOfMessageId\": \"80ad7c43-8b97-4a10-b145-8904019f6a8d\", \"source\": \"runtime_delivery\", \"messageId\": \"4c0c1175-96a2-40cc-8d6b-903bab01d715\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_TOM_USER_OK_1\", \"timestamp\": \"2026-05-08T21:15:23.513Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-82ad912c-multihop-relay\", \"displayId\": \"82ad912c\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Reply to user with gauntlet confirmation\", \"relayOfMessageId\": \"54896d68-e7f3-4b46-9716-f4c02cc5a08f\", \"source\": \"runtime_delivery\", \"messageId\": \"662edc80-bdb6-4295-8f37-1f99d85d388e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_2\", \"timestamp\": \"2026-05-08T21:15:44.993Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 2 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-2-1778274949725\", \"source\": \"runtime_delivery\", \"messageId\": \"61eab9d5-5e5e-4a9a-bcd1-5d12f3a6bd16\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_3\", \"timestamp\": \"2026-05-08T21:15:52.233Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 3 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-3-1778275302871\", \"source\": \"runtime_delivery\", \"messageId\": \"f00aa7e3-ef85-4353-8863-f87def1bc092\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_5\", \"timestamp\": \"2026-05-08T21:15:58.217Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 5 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-5-1778275307923\", \"source\": \"runtime_delivery\", \"messageId\": \"d499a34b-4bc4-4aab-ba8b-07c413182f19\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_7\", \"timestamp\": \"2026-05-08T21:16:04.658Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 7 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-7-1778275344699\", \"source\": \"runtime_delivery\", \"messageId\": \"e5ecee41-0a1a-4e15-870a-8838ffc7cdaf\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_9\", \"timestamp\": \"2026-05-08T21:16:09.443Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 9 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-9-1778275370537\", \"source\": \"runtime_delivery\", \"messageId\": \"5b4287bc-3464-472e-8c60-52d8aef631df\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_12\", \"timestamp\": \"2026-05-08T21:16:19.666Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 12 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-12-1778275389270\", \"source\": \"runtime_delivery\", \"messageId\": \"a8ea38f9-18e9-45b2-9fd4-5eeb78235c96\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_14\", \"timestamp\": \"2026-05-08T21:16:27.054Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 14 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-14-1778275399971\", \"source\": \"runtime_delivery\", \"messageId\": \"d97a37f9-c05d-4d18-8b57-3374b59fa0d2\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_16\", \"timestamp\": \"2026-05-08T21:16:33.853Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 16 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-16-1778275413442\", \"source\": \"runtime_delivery\", \"messageId\": \"c3a7190d-c1ae-4888-9c5b-88b19e5c7bac\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_18\", \"timestamp\": \"2026-05-08T21:16:38.928Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 18 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-18-1778275419904\", \"source\": \"runtime_delivery\", \"messageId\": \"60b91d11-501d-45e0-b8bb-36e6eee615af\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_21\", \"timestamp\": \"2026-05-08T21:16:43.568Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 21 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-21-1778275428583\", \"source\": \"runtime_delivery\", \"messageId\": \"0518ac52-2ffb-4d37-9e80-f8e54fe0968b\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_23\", \"timestamp\": \"2026-05-08T21:16:48.096Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 23 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-23-1778275432194\", \"source\": \"runtime_delivery\", \"messageId\": \"bee1817b-db32-4f37-9f5a-346f1d7c4c44\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_25\", \"timestamp\": \"2026-05-08T21:16:52.879Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 25 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-25-1778275435639\", \"source\": \"runtime_delivery\", \"messageId\": \"67315618-e854-4177-87b8-ea99b5222509\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_26\", \"timestamp\": \"2026-05-08T21:16:57.850Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 26 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-26-1778275438008\", \"source\": \"runtime_delivery\", \"messageId\": \"8055cecc-e72c-4649-a798-da6d8c222db7\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_27\", \"timestamp\": \"2026-05-08T21:17:02.500Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 27 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-27-1778275439877\", \"source\": \"runtime_delivery\", \"messageId\": \"51b48d44-db24-4845-ab3f-cedfdf002df8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_29\", \"timestamp\": \"2026-05-08T21:17:09.924Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 29 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-29-1778275445843\", \"source\": \"runtime_delivery\", \"messageId\": \"f508e8a7-a3de-4308-96ed-08b8e570cabe\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_31\", \"timestamp\": \"2026-05-08T21:17:15.243Z\", \"read\": false, \"taskRefs\": [ { \"taskId\": \"task-1b4c8afd-concurrent-tom\", \"displayId\": \"1b4c8afd\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ], \"summary\": \"Concurrent check reply 31 for task #1b4c8afd\", \"relayOfMessageId\": \"gauntlet-concurrent-tom-31-1778275451544\", \"source\": \"runtime_delivery\", \"messageId\": \"13fe95b3-d9e2-44da-904e-17d7be66bebb\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_BREAK\", \"timestamp\": \"2026-05-08T21:17:29.306Z\", \"read\": false, \"summary\": \"Final concurrent break acknowledgment\", \"relayOfMessageId\": \"gauntlet-concurrent-break-tom-1778275525901\", \"source\": \"runtime_delivery\", \"messageId\": \"d3a24aee-79c5-4a72-a363-7ce82882f00b\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_2\", \"timestamp\": \"2026-05-08T21:17:39.271Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 2\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-2-1778275562721\", \"source\": \"runtime_delivery\", \"messageId\": \"ee865075-7ec8-4dd7-92f2-d5b58c5a184e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_3\", \"timestamp\": \"2026-05-08T21:17:43.438Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 3\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-3-1778275572730\", \"source\": \"runtime_delivery\", \"messageId\": \"1ed75ea3-56c1-4a7d-a51d-c6bac8e0ee84\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_4\", \"timestamp\": \"2026-05-08T21:17:47.202Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 4\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-4-1778275576703\", \"source\": \"runtime_delivery\", \"messageId\": \"3969eb02-5bf5-4e34-9ccb-fc4ff2ad027f\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_5\", \"timestamp\": \"2026-05-08T21:17:51.065Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 5\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-5-1778275580427\", \"source\": \"runtime_delivery\", \"messageId\": \"4e1b0ffb-af3e-4618-a40b-7f876e14ab46\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_6\", \"timestamp\": \"2026-05-08T21:17:54.649Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 6\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-6-1778275584095\", \"source\": \"runtime_delivery\", \"messageId\": \"2a5445d7-0b5e-4534-981f-4d2d9309bed8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_7\", \"timestamp\": \"2026-05-08T21:17:59.138Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 7\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-7-1778275587643\", \"source\": \"runtime_delivery\", \"messageId\": \"741e7465-c8cc-45cb-b3b4-6b131561e1ed\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_8\", \"timestamp\": \"2026-05-08T21:18:02.903Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 8\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-8-1778275596297\", \"source\": \"runtime_delivery\", \"messageId\": \"c41285fb-4564-46cb-ac49-aef1c04a9bde\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_9\", \"timestamp\": \"2026-05-08T21:18:06.749Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 9\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-9-1778275602630\", \"source\": \"runtime_delivery\", \"messageId\": \"cf46f18e-1d1c-49d2-b4b7-26c7f8464df8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_10\", \"timestamp\": \"2026-05-08T21:18:10.921Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 10\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-10-1778275606304\", \"source\": \"runtime_delivery\", \"messageId\": \"5a3c8614-e594-4a16-866b-943096b3c73e\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_11\", \"timestamp\": \"2026-05-08T21:18:14.989Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 11\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-11-1778275609988\", \"source\": \"runtime_delivery\", \"messageId\": \"70d8715b-7984-49d2-a526-f1109609cee8\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_12\", \"timestamp\": \"2026-05-08T21:18:18.757Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 12\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-12-1778275613650\", \"source\": \"runtime_delivery\", \"messageId\": \"ca4f9ccc-4abc-4793-a006-6d9d107b3e8a\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_13\", \"timestamp\": \"2026-05-08T21:18:22.476Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 13\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-13-1778275617618\", \"source\": \"runtime_delivery\", \"messageId\": \"10c7ac23-f1e1-4450-a73e-21af460481a9\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_14\", \"timestamp\": \"2026-05-08T21:18:26.505Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 14\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-14-1778275622290\", \"source\": \"runtime_delivery\", \"messageId\": \"5e255ff3-6be9-4ae2-b9df-800846dc7101\" }, { \"from\": \"tom\", \"to\": \"user\", \"text\": \"GAUNTLET_CONCURRENT_TOM_OK_AFTER_BREAK_15\", \"timestamp\": \"2026-05-08T21:18:30.313Z\", \"read\": false, \"summary\": \"Concurrent check reply after break 15\", \"relayOfMessageId\": \"gauntlet-concurrent-after-break-tom-15-1778275625859\", \"source\": \"runtime_delivery\", \"messageId\": \"3750cace-2c71-46bf-877a-2a9446b49451\" } ] Transcript: { \"ok\": true, \"schemaVersion\": 1, \"requestId\": \"opencode-bridge-74789383-205c-442c-bb52-885fdffdb00b\", \"command\": \"opencode.getRuntimeTranscript\", \"completedAt\": \"2026-05-08T21:18:39.091Z\", \"durationMs\": 1965, \"runtime\": { \"providerId\": \"opencode\", \"binaryPath\": \"opencode\", \"binaryFingerprint\": \"version:1.14.19\", \"version\": \"1.14.19\", \"capabilitySnapshotId\": \"opencode:8295cf1215cc187732f1b9168503622b\" }, \"diagnostics\": [], \"data\": { \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"durableState\": \"reply_pending\", \"messages\": [ { \"id\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"parentId\": null, \"role\": \"user\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274879442, \"completedAt\": null, \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\", \"reasoningText\": \"\", \"previewText\": \" You are an OpenCode teammate managed by the desktop app. Runtime identity reminder for this delivered app message. This session i...\", \"partTypes\": [ \"text\" ], \"reasoningPartCount\": 0, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\" } ], \"finishReason\": null, \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"parentId\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"role\": \"assistant\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274879446, \"completedAt\": 1778274887054, \"text\": \"\", \"reasoningText\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\", \"previewText\": null, \"partTypes\": [ \"step-start\", \"reasoning\", \"tool\", \"step-finish\" ], \"reasoningPartCount\": 1, \"toolCalls\": [ { \"callId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"toolName\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"output\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"outputText\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"outputPreview\": \"{ \\\"deliveredToInbox\\\": true, \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\", \\\"message\\\": { \\\"from\\\": \\\"bob\\\", \\\"to\\\": \\\"jack\\\", \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK...\", \"startedAt\": 1778274886921, \"completedAt\": 1778274886952, \"isError\": false } ], \"contentBlocks\": [ { \"type\": \"step_start\" }, { \"type\": \"reasoning\", \"text\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\" }, { \"type\": \"tool_use\", \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"startedAt\": 1778274886921, \"completedAt\": 1778274886952 }, { \"type\": \"tool_result\", \"toolUseId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"toolName\": \"agent-teams_message_send\", \"title\": null, \"status\": \"completed\", \"contentText\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"contentPreview\": \"{ \\\"deliveredToInbox\\\": true, \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\", \\\"message\\\": { \\\"from\\\": \\\"bob\\\", \\\"to\\\": \\\"jack\\\", \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK...\", \"rawContent\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"isError\": false, \"startedAt\": 1778274886921, \"completedAt\": 1778274886952 }, { \"type\": \"step_finish\", \"reason\": \"tool-calls\" } ], \"finishReason\": \"tool-calls\", \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_e09716590001lO20OvwnQOa4do\", \"parentId\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"role\": \"assistant\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274887056, \"completedAt\": 1778274889015, \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\", \"reasoningText\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\", \"previewText\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\", \"partTypes\": [ \"step-start\", \"reasoning\", \"text\", \"step-finish\" ], \"reasoningPartCount\": 1, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"step_start\" }, { \"type\": \"reasoning\", \"text\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\" }, { \"type\": \"text\", \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\" }, { \"type\": \"step_finish\", \"reason\": \"stop\" } ], \"finishReason\": \"stop\", \"errorName\": null, \"errorMessage\": null, \"hasError\": false }, { \"id\": \"msg_5228a61df8294358b8feb5aceec27d2c\", \"parentId\": null, \"role\": \"user\", \"agent\": \"teammate\", \"providerId\": \"opencode\", \"modelId\": \"big-pickle\", \"createdAt\": 1778274933336, \"completedAt\": null, \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\", \"reasoningText\": \"\", \"previewText\": \" You are an OpenCode teammate managed by the desktop app. Runtime identity reminder for this delivered app message. This session i...\", \"partTypes\": [ \"text\" ], \"reasoningPartCount\": 0, \"toolCalls\": [], \"contentBlocks\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\" } ], \"finishReason\": null, \"errorName\": null, \"errorMessage\": null, \"hasError\": false } ], \"logProjection\": { \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"durableState\": \"reply_pending\", \"sourceMessageCount\": 4, \"projectedMessageCount\": 5, \"syntheticMessageCount\": 1, \"toolCallCount\": 1, \"errorCount\": 0, \"diagnostics\": [ \"OpenCode session is stale (managed_auth_changed); reading historical messages for log projection only\", \"OpenCode session reconcile skipped because the stored session is stale (managed_auth_changed)\" ], \"messages\": [ { \"uuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"parentUuid\": null, \"type\": \"user\", \"timestamp\": \"2026-05-08T21:14:39.442Z\", \"role\": \"user\", \"content\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-peer-ab-1-1778274876098\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"jack\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-peer-ab-1-1778274876098\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: delegate.\\n\\n\\n\\nSend one team message to jack about task #3375c939.\\nThe teammate message must include GAUNTLET_PEER_BOB_TO_JACK_OK_1.\\nAsk jack to reply to the app user with GAUNTLET_JACK_USER_OK_1.\\nUse agent-teams_message_send to jack, not a plain assistant answer.\\n\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] }, { \"uuid\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"parentUuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"type\": \"assistant\", \"timestamp\": \"2026-05-08T21:14:39.446Z\", \"role\": \"assistant\", \"content\": [ { \"type\": \"thinking\", \"thinking\": \"The inbound message asks me to:\\n1. Send a team message to jack about task #3375c939\\n2. Include \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1\\\" in the message\\n3. Ask jack to reply to the app user with \\\"GAUNTLET_JACK_USER_OK_1\\\"\\n4. Use agent-teams_message_send to jack, not a plain assistant answer\\n\\nI need to call agent-teams_message_send with:\\n- teamName: \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n- to: \\\"jack\\\"\\n- from: \\\"bob\\\"\\n- text: message including GAUNTLET_PEER_BOB_TO_JACK_OK_1 and asking jack to reply with GAUNTLET_JACK_USER_OK_1\\n- summary: brief summary\\n- source: \\\"runtime_delivery\\\"\\n- relayOfMessageId: \\\"gauntlet-peer-ab-1-1778274876098\\\"\\n- taskRefs: [{\\\"taskId\\\":\\\"task-3375c939-peer-relay\\\",\\\"displayId\\\":\\\"3375c939\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]\", \"signature\": \"opencode\" }, { \"type\": \"tool_use\", \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] } } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [ { \"id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"name\": \"agent-teams_message_send\", \"input\": { \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\", \"to\": \"jack\", \"from\": \"bob\", \"text\": \"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\", \"summary\": \"Peer relay from bob to jack for task #3375c939\", \"source\": \"runtime_delivery\", \"relayOfMessageId\": \"gauntlet-peer-ab-1-1778274876098\", \"taskRefs\": [ { \"taskId\": \"task-3375c939-peer-relay\", \"displayId\": \"3375c939\", \"teamName\": \"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\" } ] }, \"isTask\": false } ], \"toolResults\": [] }, { \"uuid\": \"msg_e097147d6001BiPpgEgwLzYkib::tool_results\", \"parentUuid\": \"msg_e097147d6001BiPpgEgwLzYkib\", \"type\": \"user\", \"timestamp\": \"2026-05-08T21:14:47.054Z\", \"role\": \"user\", \"content\": [ { \"type\": \"tool_result\", \"tool_use_id\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"content\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": true, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [ { \"toolUseId\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"content\": \"{\\n \\\"deliveredToInbox\\\": true,\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\",\\n \\\"message\\\": {\\n \\\"from\\\": \\\"bob\\\",\\n \\\"to\\\": \\\"jack\\\",\\n \\\"text\\\": \\\"GAUNTLET_PEER_BOB_TO_JACK_OK_1 — Jack, please reply to the app user with GAUNTLET_JACK_USER_OK_1.\\\",\\n \\\"timestamp\\\": \\\"2026-05-08T21:14:46.946Z\\\",\\n \\\"read\\\": false,\\n \\\"taskRefs\\\": [\\n {\\n \\\"taskId\\\": \\\"task-3375c939-peer-relay\\\",\\n \\\"displayId\\\": \\\"3375c939\\\",\\n \\\"teamName\\\": \\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"\\n }\\n ],\\n \\\"summary\\\": \\\"Peer relay from bob to jack for task #3375c939\\\",\\n \\\"relayOfMessageId\\\": \\\"gauntlet-peer-ab-1-1778274876098\\\",\\n \\\"source\\\": \\\"runtime_delivery\\\",\\n \\\"messageId\\\": \\\"80ad7c43-8b97-4a10-b145-8904019f6a8d\\\"\\n },\\n \\\"protocolInstruction\\\": \\\"Delivered as an app-delivered runtime reply. Stop this turn now; do not call message_send again for the same inbound message.\\\"\\n}\", \"isError\": false } ], \"sourceToolUseID\": \"call_00_djSCNM3InAHfaSPy7IdA5127\", \"sourceToolAssistantUUID\": \"msg_e097147d6001BiPpgEgwLzYkib\" }, { \"uuid\": \"msg_e09716590001lO20OvwnQOa4do\", \"parentUuid\": \"msg_c9c4ae0456ce437a99a559e5c6d157b9\", \"type\": \"assistant\", \"timestamp\": \"2026-05-08T21:14:47.056Z\", \"role\": \"assistant\", \"content\": [ { \"type\": \"thinking\", \"thinking\": \"The message was successfully delivered to jack's inbox. The protocol instruction says to stop this turn now. I should not send any follow-up.\", \"signature\": \"opencode\" }, { \"type\": \"text\", \"text\": \"Message delivered to jack with GAUNTLET_PEER_BOB_TO_JACK_OK_1 and request for GAUNTLET_JACK_USER_OK_1.\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] }, { \"uuid\": \"msg_5228a61df8294358b8feb5aceec27d2c\", \"parentUuid\": null, \"type\": \"user\", \"timestamp\": \"2026-05-08T21:15:33.336Z\", \"role\": \"user\", \"content\": [ { \"type\": \"text\", \"text\": \"\\nYou are an OpenCode teammate managed by the desktop app.\\nRuntime identity reminder for this delivered app message. This session is already attached by the app; do not call runtime_bootstrap_checkin just to answer this delivered message unless the app explicitly asks you to re-check in.\\nAttached runtime context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"runId\\\":\\\"375a5319-7775-479c-a79b-3bfb1392c555\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeSessionId\\\":\\\"ses_1f68eba5fffeBiB3HqK4m88AWX\\\"}\\nDo not call member_briefing just to answer this delivered message unless the app explicitly asks you to refresh teammate rules.\\nAttached teammate context: {\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"memberName\\\":\\\"bob\\\",\\\"runtimeProvider\\\":\\\"opencode\\\"}\\nFor this delivered app message, prioritize the requested visible reply through message_send.\\nOnly send a visible message when you are replying to this delivered app message or reporting real task/blocker/result context.\\nDo not send unrelated readiness, understood, idle, or no-task acknowledgements.\\nFor visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.\\nDo not use runtime_deliver_message for ordinary visible replies unless a runtime-delivery prompt explicitly provides runId/runtimeSessionId/idempotencyKey.\\n\\n\\n\\n{\\\"schemaVersion\\\":1,\\\"kind\\\":\\\"opencode-delivery-context\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\",\\\"laneId\\\":\\\"primary\\\",\\\"memberName\\\":\\\"bob\\\",\\\"inboundMessageId\\\":\\\"gauntlet-concurrent-bob-1-1778274929169\\\",\\\"taskRefs\\\":[{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}]}\\nYou are running in OpenCode, not Claude Code or Codex native.\\nTo make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).\\nUse teamName=\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\", to=\\\"user\\\", from=\\\"bob\\\", text, and summary.\\nInclude source=\\\"runtime_delivery\\\" in that message_send call.\\nInclude relayOfMessageId=\\\"gauntlet-concurrent-bob-1-1778274929169\\\" in that message_send call.\\nIf taskRefs are present in , include taskRefs exactly as provided in that message_send call: [{\\\"taskId\\\":\\\"task-9e2f74aa-concurrent-bob\\\",\\\"displayId\\\":\\\"9e2f74aa\\\",\\\"teamName\\\":\\\"opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1\\\"}].\\nIf message_send returns an unavailable, not connected, or missing-tool error, write the exact concise reply as plain assistant text once, then stop.\\nAfter the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.\\nYou must not end this turn empty.\\nDo not answer only with plain assistant text when agent-teams_message_send is available.\\nDo not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.\\nDo not use SendMessage or runtime_deliver_message for ordinary visible replies.\\nDo not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.\\nThe inbound app message follows. Treat it as the actual instruction to process now, not as background context.\\nIf the inbound message asks for exact reply text, use that exact text. Do not replace concrete instructions with a generic greeting or availability message.\\nAction mode for this message: ask.\\n\\n\\n\\nConcurrent check for task #9e2f74aa.\\nReply to user with GAUNTLET_CONCURRENT_BOB_OK_1.\\nThis message is intentionally sent near another teammate delivery.\\n\" } ], \"model\": \"opencode/big-pickle\", \"agentName\": \"teammate\", \"isMeta\": false, \"sessionId\": \"ses_1f68eba5fffeBiB3HqK4m88AWX\", \"toolCalls\": [], \"toolResults\": [] } ] }, \"diagnostics\": [ \"OpenCode session is stale (managed_auth_changed); reading historical messages for log projection only\", \"OpenCode session reconcile skipped because the stored session is stale (managed_auth_changed)\" ] } }", + "duplicateOrMissingTokens=GAUNTLET_CONCURRENT_BOB_OK_1,GAUNTLET_CONCURRENT_TOM_OK_1" ] } ] diff --git a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md index 614e4d17..0cac842f 100644 --- a/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md +++ b/test-results/opencode-semantic-model-gauntlet/model-gauntlet-results.md @@ -1,6 +1,6 @@ # OpenCode Model Gauntlet Results -Generated: 2026-05-08T18:34:37.950Z +Generated: 2026-05-08T21:13:58.089Z Runs per model: 1 Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0 @@ -13,25 +13,25 @@ Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC | Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 | | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms | +| `opencode/big-pickle` | Infra blocked | blocked | 0 | 0 | 0 | n/a | 70 | 0/1 | 0/1 | concurrentReplies 0/1 (0%) | concurrentBob 0/1 (0%) | provider-infra | overall average 70 < 80; successful runs 0 < 1; consistency score 0 < 85; provider-infra failures 1; highest weighted stage loss concurrentReplies=15; weakest taskRefs concurrentBob=0/1 (0%); protocol violations in 1 runs | 1 | 0 | 0 | 1 | 281016ms | 281016ms | ## opencode/big-pickle -Readiness score: 73. +Readiness score: 0. -Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1. +Score stability: n/a. -Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%). +Recommendation blockers: overall average 70 < 80; successful runs 0 < 1; consistency score 0 < 85; provider-infra failures 1; highest weighted stage loss concurrentReplies=15; weakest taskRefs concurrentBob=0/1 (0%); protocol violations in 1 runs. -Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%). +Weighted stage impact: concurrentReplies:loss=15, failed=1, pass=0/1 (0%); taskRefs:loss=10, failed=1, pass=0/1 (0%); noDuplicateTokens:loss=5, failed=1, pass=0/1 (0%). -Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%). +Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:0/1 (0%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:0/1 (0%), latencyStable:1/1 (100%). TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%). -Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0. +Protocol totals: badMessages=0, duplicateOrMissingTokens=2, affectedRuns=1. | Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics | | ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- | -| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 | +| 1 | provider-infra-blocked | provider-infra | 70 | no | 281016ms | concurrentReplies, taskRefs, noDuplicateTokens | concurrentReplies:189928ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | token=GAUNTLET_CONCURRENT_BOB_OK_1+GAUNTLET_CONCURRENT_TOM_OK_1 | concurrentBob: Timed out waiting for OpenCode reply in /var/folders/7b/ydmc_b0n251bc4hss4tz8y880000gn/T/opencode-semantic-gauntlet-ZwZPyq/.claude/teams/opencode-semantic-realistic-gauntlet-opencode-big-pickle-1778274838090-1/inboxes/user.js | diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index b3e60e9c..c935f23d 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -312,6 +312,7 @@ describe('ipc teams handlers', () => { getAliveTeams: vi.fn(() => ['my-team']), getLeadActivityState: vi.fn(() => 'idle'), stopTeam: vi.fn(() => Promise.resolve()), + repairStaleTaskActivityIntervalsBeforeSnapshot: vi.fn(() => Promise.resolve(undefined)), reattachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), detachOpenCodeOwnedMemberLane: vi.fn(async () => undefined), }; @@ -369,6 +370,8 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset(); provisioningService.resolveRuntimeRecipientProviderId.mockReset(); provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValue(undefined); + provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockReset(); + provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mockResolvedValue(undefined); launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 }); initializeTeamHandlers( service as never, @@ -1377,6 +1380,32 @@ describe('ipc teams handlers', () => { expect(service.getTeamData).not.toHaveBeenCalled(); }); + it('repairs stale task activity before reading TEAM_GET_DATA through the worker', async () => { + mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); + mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [], + members: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + processes: [], + }); + + const handler = handlers.get(TEAM_GET_DATA)!; + const result = (await handler({} as never, 'my-team')) as { + success: boolean; + data?: { teamName: string }; + }; + + expect(result.success).toBe(true); + expect(provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot).toHaveBeenCalledWith( + 'my-team' + ); + expect( + provisioningService.repairStaleTaskActivityIntervalsBeforeSnapshot.mock.invocationCallOrder[0] + ).toBeLessThan(mockTeamDataWorkerClient.getTeamData.mock.invocationCallOrder[0]); + }); + it('normalizes explicit full TEAM_GET_DATA options to the existing one-argument call shape', async () => { mockTeamDataWorkerClient.isAvailable.mockReturnValue(true); mockTeamDataWorkerClient.getTeamData.mockResolvedValueOnce({ diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index 5ca3adf1..6759ba7c 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -126,6 +126,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-'); expect(directCommand?.text).toContain('Action mode for this message: ask.'); expect(directCommand?.text).toContain('You must not end this turn empty.'); + expect(directCommand?.text).toContain('include taskRefs exactly as provided'); expect(directCommand?.text).toContain('"displayId":"59560c95"'); expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message'); expect(directCommand?.text).toContain('never use #00000000'); @@ -143,12 +144,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => { if (process.env.OPENCODE_E2E_DUMP_PROMPTS === '1') { await dumpOpenCodePromptArtifacts({ - outputDir: path.join( - process.cwd(), - 'test-results', - 'opencode-semantic-prompts', - teamName - ), + outputDir: path.join(process.cwd(), 'test-results', 'opencode-semantic-prompts', teamName), launchInput: launchInput!, launchCommand: launchCommand!, messageCommands: bridgeCapture.messageCommands, diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index 4b633499..ab3b87a4 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -267,6 +267,36 @@ describe('OpenCodePromptDeliveryLedger', () => { ).toBe(true); }); + it('preserves missing taskRefs as the pending reason for insufficient destination proof', async () => { + const store = createStore(); + const record = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-taskrefs', + inboxTimestamp: '2026-04-25T09:59:00.000Z', + source: 'watcher', + replyRecipient: 'user', + payloadHash: 'sha256:taskrefs', + now: '2026-04-25T10:00:00.000Z', + }); + + const missingTaskRefs = await store.applyDestinationProof({ + id: record.id, + visibleReplyInbox: 'user', + visibleReplyMessageId: 'reply-taskrefs', + visibleReplyCorrelation: 'relayOfMessageId', + semanticallySufficient: false, + diagnostics: ['visible_reply_missing_task_refs_after_merge'], + observedAt: '2026-04-25T10:00:01.000Z', + }); + + expect(missingTaskRefs.status).toBe('pending'); + expect(missingTaskRefs.responseState).toBe('responded_visible_message'); + expect(missingTaskRefs.lastReason).toBe('visible_reply_missing_task_refs'); + expect(missingTaskRefs.diagnostics).toContain('visible_reply_missing_task_refs_after_merge'); + }); + it('records empty assistant delivery results as unanswered and stores plain text previews', async () => { const store = createStore(); const unanswered = await store.ensurePending({ diff --git a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts index 73d4d653..06c53520 100644 --- a/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryRepairPolicy.test.ts @@ -58,6 +58,25 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => { expect(decision.controlText).not.toContain('reportToken='); }); + it('repairs visible replies that missed required taskRefs with exact metadata', () => { + const taskRef = { taskId: 'task-refs-1', displayId: 'refs-1', teamName: 'team-a' }; + const decision = decideOpenCodePromptDeliveryRepair( + base({ + taskRefs: [taskRef], + responseState: 'responded_visible_message', + pendingReason: 'visible_reply_missing_task_refs', + visibleReplyFound: true, + }) + ); + + expect(decision.kind).toBe('missing_visible_reply_correlation'); + expect(decision.retryable).toBe(true); + expect(decision.controlText).toContain('relayOfMessageId="msg-1"'); + expect(decision.controlText).toContain( + `Include taskRefs exactly as this JSON array: ${JSON.stringify([taskRef])}.` + ); + }); + it('does not repair terminal, permission, or session failures', () => { expect( decideOpenCodePromptDeliveryRepair( diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 090a4bd7..b530e445 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -137,6 +137,70 @@ describe('OpenCodeReadinessBridge', () => { ); }); + it('gives observeMessageDelivery enough time for OpenCode plain-text fallback reconciliation', async () => { + const executor = fakeExecutor( + bridgeCommandSuccess({ + command: 'opencode.observeMessageDelivery', + requestId: 'observe-req-1', + data: { + observed: true, + memberName: 'tom', + sessionId: 'session-tom', + diagnostics: [], + responseObservation: { + state: 'responded_plain_text', + deliveredUserMessageId: 'user-message-1', + assistantMessageId: 'assistant-message-1', + toolCallNames: ['message_send'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: 'plain_assistant_text', + latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1', + reason: 'assistant_replied_with_plain_text', + }, + }, + }) + ); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.observeOpenCodeTeamMessageDelivery({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'primary', + runId: 'run-1', + projectPath: '/repo', + memberName: 'tom', + messageId: 'gauntlet-concurrent-tom-1', + prePromptCursor: 'cursor-before', + }) + ).resolves.toMatchObject({ + observed: true, + responseObservation: { + state: 'responded_plain_text', + latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1', + }, + }); + + expect(executor.execute).toHaveBeenCalledWith( + 'opencode.observeMessageDelivery', + { + teamId: 'team-a', + teamName: 'team-a', + laneId: 'primary', + runId: 'run-1', + projectPath: '/repo', + memberName: 'tom', + messageId: 'gauntlet-concurrent-tom-1', + prePromptCursor: 'cursor-before', + }, + { + cwd: '/repo', + timeoutMs: 20_000, + } + ); + }); + it('executes OpenCode task ledger backfill through a direct read-only bridge command', async () => { const executor = fakeExecutor( bridgeCommandSuccess({ diff --git a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts index 80d209a0..9b954869 100644 --- a/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/main/services/team/OpenCodeRuntimeDeliveryDiagnostics.test.ts @@ -44,4 +44,30 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => { 'OpenCode used tools, but did not create a visible reply or task progress proof.' ); }); + + it('formats visible replies missing taskRefs without exposing the internal reason code', () => { + const record = { + diagnostics: ['visible_reply_missing_task_refs'], + lastReason: 'visible_reply_missing_task_refs', + responseState: 'responded_visible_message', + status: 'failed_terminal', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode created a reply without the required taskRefs metadata.' + ); + }); + + it('formats taskRefs merge verification failures without exposing internal diagnostics', () => { + const record = { + diagnostics: ['visible_reply_missing_task_refs_after_merge'], + lastReason: 'visible_reply_ack_only_still_requires_answer', + responseState: 'responded_visible_message', + status: 'failed_terminal', + } as Parameters[0]; + + expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe( + 'OpenCode created a reply without the required taskRefs metadata.' + ); + }); }); diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index a6e9cb77..d2cc3d28 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -536,7 +536,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain(''); expect(sentText).toContain('"kind":"opencode-delivery-context"'); expect(sentText).toContain('"inboundMessageId":"msg-1"'); - expect(sentText).not.toContain('include taskRefs exactly'); + expect(sentText).toContain('include taskRefs exactly as provided'); expect(sentText).not.toContain('The inbound app messageId is'); expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message'); expect(sentText).toContain('never use #00000000'); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 60380b9e..8c345400 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -48,7 +48,7 @@ import { RuntimeStoreBatchWriter, } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; -import type { TeamProvisioningProgress } from '../../../../src/shared/types'; +import type { InboxMessage, TaskRef, TeamProvisioningProgress } from '../../../../src/shared/types'; const LAUNCH_MATRIX_SAFE_E2E_TIMEOUT_MS = 60_000; @@ -10539,6 +10539,126 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('inherits OpenCode runtime delivery taskRefs end-to-end when the visible reply omits them', async () => { + const teamName = 'pure-opencode-runtime-delivery-taskrefs-inherit-safe-e2e'; + const taskRef: TaskRef = { + teamName, + taskId: 'task-runtime-delivery-1', + displayId: 'abcd1234', + }; + const adapter = new VisibleReplyOpenCodeRuntimeAdapter({ + replySource: 'runtime_delivery', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'reply about #abcd1234 without manually carrying metadata', + messageId: 'msg-taskrefs-inherit-e2e', + replyRecipient: 'user', + actionMode: 'ask', + source: 'manual', + taskRefs: [taskRef], + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'responded', + visibleReplyMessageId: 'reply-msg-taskrefs-inherit-e2e', + visibleReplyCorrelation: 'relayOfMessageId', + }); + + expect(adapter.messageInputs).toHaveLength(1); + expect(adapter.messageInputs[0]).toMatchObject({ + runId: launch.runId, + teamName, + laneId: 'primary', + memberName: 'alice', + messageId: 'msg-taskrefs-inherit-e2e', + taskRefs: [taskRef], + }); + + const userInbox = await readInboxRows(teamName, 'user'); + expect(userInbox).toHaveLength(1); + expect(userInbox[0]).toMatchObject({ + from: 'alice', + to: 'user', + source: 'runtime_delivery', + messageId: 'reply-msg-taskrefs-inherit-e2e', + relayOfMessageId: 'msg-taskrefs-inherit-e2e', + taskRefs: [taskRef], + }); + }); + + it('does not attach taskRefs end-to-end to explicit non-runtime visible replies', async () => { + const teamName = 'pure-opencode-runtime-delivery-taskrefs-non-runtime-safe-e2e'; + const taskRef: TaskRef = { + teamName, + taskId: 'task-runtime-delivery-2', + displayId: 'dcba4321', + }; + const adapter = new VisibleReplyOpenCodeRuntimeAdapter({ + replySource: 'lead_process', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'this reply has a misleading non-runtime source', + messageId: 'msg-taskrefs-non-runtime-e2e', + replyRecipient: 'user', + actionMode: 'ask', + source: 'manual', + taskRefs: [taskRef], + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'responded_visible_message', + reason: 'visible_reply_missing_task_refs', + }); + + const userInbox = await readInboxRows(teamName, 'user'); + expect(userInbox).toHaveLength(1); + expect(userInbox[0]).toMatchObject({ + from: 'alice', + to: 'user', + source: 'lead_process', + messageId: 'reply-msg-taskrefs-non-runtime-e2e', + relayOfMessageId: 'msg-taskrefs-non-runtime-e2e', + }); + expect(userInbox[0]?.taskRefs).toBeUndefined(); + }); + it('delivers direct OpenCode member messages to recovered pure OpenCode lanes after service restart', async () => { const teamName = 'pure-opencode-direct-message-recovered-lane-safe-e2e'; const launchAdapter = new FakeOpenCodeRuntimeAdapter(); @@ -17111,6 +17231,58 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { } } +class VisibleReplyOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { + constructor(private readonly options: { replySource: InboxMessage['source'] }) { + super(); + } + + override async sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise { + const result = await super.sendMessageToMember(input); + const relayOfMessageId = input.messageId?.trim() || `message-${this.messageInputs.length}`; + const replyRecipient = input.replyRecipient?.trim() || 'user'; + const replyMessageId = `reply-${relayOfMessageId}`; + const inboxPath = path.join( + getTeamsBasePath(), + input.teamName, + 'inboxes', + `${replyRecipient}.json` + ); + const rows: InboxMessage[] = await readInboxRows(input.teamName, replyRecipient).catch( + () => [] + ); + rows.push({ + from: input.memberName, + to: replyRecipient, + text: `Visible reply for ${relayOfMessageId}`, + summary: 'visible reply', + timestamp: '2026-05-08T10:00:00.000Z', + read: false, + messageId: replyMessageId, + relayOfMessageId, + source: this.options.replySource, + }); + await fs.mkdir(path.dirname(inboxPath), { recursive: true }); + await fs.writeFile(inboxPath, `${JSON.stringify(rows, null, 2)}\n`, 'utf8'); + + return { + ...result, + responseObservation: { + state: 'responded_visible_message', + deliveredUserMessageId: `delivered-${relayOfMessageId}`, + assistantMessageId: `assistant-${relayOfMessageId}`, + toolCallNames: ['message_send'], + visibleMessageToolCallId: `call-${relayOfMessageId}`, + visibleReplyMessageId: replyMessageId, + visibleReplyCorrelation: 'relayOfMessageId', + latestAssistantPreview: null, + reason: 'visible_message_sent', + }, + }; + } +} + class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = []; @@ -18380,6 +18552,13 @@ function getMixedPrimaryFixture(providerId: MixedPrimaryProviderId = 'codex'): { }; } +async function readInboxRows(teamName: string, inboxName: string): Promise { + const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${inboxName}.json`); + const raw = await fs.readFile(inboxPath, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? (parsed as InboxMessage[]) : []; +} + async function writeTeamMeta( teamName: string, projectPath: string, diff --git a/test/main/services/team/TeamInboxWriter.test.ts b/test/main/services/team/TeamInboxWriter.test.ts index 92f03c19..08bf1468 100644 --- a/test/main/services/team/TeamInboxWriter.test.ts +++ b/test/main/services/team/TeamInboxWriter.test.ts @@ -221,6 +221,147 @@ describe('TeamInboxWriter', () => { }); }); + it('merges taskRefs when deduplicating repeated runtime delivery replies', async () => { + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }; + const first = await writer.sendMessage('my-team', { + member: 'user', + from: 'alice', + to: 'user', + text: 'Готово по задаче.', + source: 'runtime_delivery', + relayOfMessageId: 'inbound-task-1', + }); + const second = await writer.sendMessage('my-team', { + member: 'user', + from: 'alice', + to: 'user', + text: ' Готово по задаче. ', + source: 'runtime_delivery', + relayOfMessageId: 'inbound-task-1', + taskRefs: [taskRef], + }); + + const userInboxPath = '/mock/teams/my-team/inboxes/user.json'; + const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record< + string, + unknown + >[]; + expect(second).toMatchObject({ + deliveredToInbox: true, + deduplicated: true, + messageId: first.messageId, + }); + expect(persisted).toHaveLength(1); + expect(persisted[0].taskRefs).toEqual([taskRef]); + }); + + it('merges taskRefs into an exact runtime delivery reply row', async () => { + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }; + const written = await writer.sendMessage('my-team', { + member: 'user', + from: 'alice', + to: 'user', + text: 'Готово по задаче.', + source: 'runtime_delivery', + relayOfMessageId: 'inbound-task-1', + messageId: 'reply-1', + }); + + const result = await writer.mergeRuntimeDeliveryTaskRefs('my-team', { + inboxName: 'user', + messageId: written.messageId, + relayOfMessageId: 'inbound-task-1', + from: 'alice', + taskRefs: [taskRef], + }); + + const userInboxPath = '/mock/teams/my-team/inboxes/user.json'; + const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record< + string, + unknown + >[]; + expect(result).toMatchObject({ + found: true, + updated: true, + message: { + messageId: 'reply-1', + taskRefs: [taskRef], + }, + }); + expect(persisted).toHaveLength(1); + expect(persisted[0].taskRefs).toEqual([taskRef]); + }); + + it('does not merge taskRefs into explicit non-runtime reply rows', async () => { + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }; + await writer.sendMessage('my-team', { + member: 'user', + from: 'alice', + to: 'user', + text: 'Lead process reply.', + source: 'lead_process', + relayOfMessageId: 'inbound-task-1', + messageId: 'reply-1', + }); + + const result = await writer.mergeRuntimeDeliveryTaskRefs('my-team', { + inboxName: 'user', + messageId: 'reply-1', + relayOfMessageId: 'inbound-task-1', + from: 'alice', + taskRefs: [taskRef], + }); + + const userInboxPath = '/mock/teams/my-team/inboxes/user.json'; + const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record< + string, + unknown + >[]; + expect(result).toEqual({ found: false, updated: false }); + expect(persisted[0]).not.toHaveProperty('taskRefs'); + }); + + it('repairs relayOfMessageId on a runtime delivery reply matched by messageId', async () => { + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'my-team' }; + await writer.sendMessage('my-team', { + member: 'user', + from: 'alice', + to: 'user', + text: 'Visible answer.', + source: 'runtime_delivery', + relayOfMessageId: 'hallucinated-inbound-id', + messageId: 'reply-1', + }); + + const result = await writer.correlateRuntimeDeliveryReply('my-team', { + inboxName: 'user', + messageId: 'reply-1', + relayOfMessageId: 'real-inbound-id', + from: 'alice', + taskRefs: [taskRef], + }); + + const userInboxPath = '/mock/teams/my-team/inboxes/user.json'; + const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record< + string, + unknown + >[]; + expect(result).toMatchObject({ + found: true, + updated: true, + message: { + messageId: 'reply-1', + relayOfMessageId: 'real-inbound-id', + taskRefs: [taskRef], + }, + }); + expect(persisted).toHaveLength(1); + expect(persisted[0]).toMatchObject({ + relayOfMessageId: 'real-inbound-id', + taskRefs: [taskRef], + }); + }); + it('omits source field from payload when not provided in request', async () => { await writer.sendMessage('my-team', { member: 'alice', diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 63bffa5a..764ef3a0 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -1080,6 +1080,152 @@ describe('TeamMemberLogsFinder', () => { ); }); + it('findLogsForTask does not treat malformed empty completedAt intervals as open', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-malformed-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 't5-malformed'; + const projectPath = '/Users/test/proj5-malformed'; + const projectId = '-Users-test-proj5-malformed'; + const leadSessionId = 's5-malformed'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'bob', agentType: 'general-purpose' }, + { name: 'alice', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: `You are alice, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'TaskUpdate', + input: { team_name: teamName, taskId: '10', status: 'pending' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-near.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T10:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: `You are bob, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T10:00:02.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Near malformed interval' }], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-late.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T12:00:00.000Z', + type: 'user', + message: { + role: 'user', + content: `You are bob, a developer on team "${teamName}" (${teamName}).`, + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T12:00:01.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Late malformed interval' }], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const options = { + owner: 'bob', + status: 'in_progress', + intervals: [{ startedAt: '2026-01-01T10:00:00.000Z', completedAt: '' }], + }; + const logs = await finder.findLogsForTask(teamName, '10', options); + + const bobFilePaths = logs + .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') + .map((l) => l.filePath ?? ''); + + expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe(true); + expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false); + + const reversedIntervalLogs = await finder.findLogsForTask(teamName, '10', { + ...options, + intervals: [ + { + startedAt: '2026-01-01T10:00:00.000Z', + completedAt: '2026-01-01T09:59:00.000Z', + }, + ], + }); + const reversedBobFilePaths = reversedIntervalLogs + .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') + .map((l) => l.filePath ?? ''); + + expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe( + true + ); + expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe( + false + ); + + const refs = await finder.findLogFileRefsForTask(teamName, '10', options); + const bobRefPaths = refs + .filter((ref) => ref.memberName.toLowerCase() === 'bob') + .map((ref) => ref.filePath); + + expect(bobRefPaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false); + }); + it('findLogsForTask does not auto-include owner sessions when owner is team-lead', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-lead-owner-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts index f949c2e4..63382f01 100644 --- a/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts +++ b/test/main/services/team/TeamMemberRuntimeAdvisoryService.test.ts @@ -170,6 +170,10 @@ describe('TeamMemberRuntimeAdvisoryService', () => { ['codex_native_timeout', 'Codex native exec timed out after 120000ms.'], ['network_error', 'Fetch failed because the network connection timed out.'], ['provider_overloaded', 'Service unavailable: provider temporarily unavailable (503).'], + [ + 'protocol_proof_missing', + 'OpenCode created a reply without the required taskRefs metadata.', + ], ['backend_error', 'Unexpected backend blew up during request processing.'], ] as const)('classifies %s retry causes from api_error messages', async (expected, message) => { const service = new TeamMemberRuntimeAdvisoryService({} as never); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index a0cd9245..6d1f614f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -126,6 +126,7 @@ import { getMixedLaunchFallbackRecoveryError, TeamProvisioningService, } from '@main/services/team/TeamProvisioningService'; +import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService'; import { clearAutoResumeService, getAutoResumeService, @@ -1524,6 +1525,218 @@ describe('TeamProvisioningService', () => { expect(refreshLeadInbox).toHaveBeenCalledTimes(2); }); + it('pauses member task intervals at last runtime evidence plus grace when runtime goes offline', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z')); + const pauseSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'pauseActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + const svc = new TeamProvisioningService(); + const teamName = 'spawn-runtime-offline-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-05-02T10:00:02.000Z', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + lastHeartbeatAt: '2026-05-02T10:00:00.000Z', + livenessLastCheckedAt: '2026-05-02T10:00:01.000Z', + }, + ], + ]), + }); + + (svc as any).setMemberSpawnStatus(run, 'alice', 'offline'); + + expect(pauseSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:06.000Z'); + }); + + it('pauses member task intervals when snapshot sync observes runtime loss', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z')); + const pauseSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'pauseActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + const svc = new TeamProvisioningService(); + const teamName = 'spawn-runtime-snapshot-offline-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-05-02T10:00:02.000Z', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + lastHeartbeatAt: '2026-05-02T10:00:00.000Z', + livenessLastCheckedAt: '2026-05-02T10:00:01.000Z', + }, + ], + ]), + }); + const snapshot = createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'finished', + members: { + alice: { + name: 'alice', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Runtime disappeared before finalization.', + lastEvaluatedAt: '2026-05-02T10:04:00.000Z', + }, + }, + }); + + (svc as any).syncRunMemberSpawnStatusesFromSnapshot(run, snapshot); + + expect(pauseSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:06.000Z'); + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'error', + runtimeAlive: false, + launchState: 'failed_to_start', + }); + }); + + it('resumes member task intervals at the heartbeat evidence time when runtime comes online', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z')); + const resumeSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + const svc = new TeamProvisioningService(); + const teamName = 'spawn-runtime-online-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T09:59:00.000Z', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }, + ], + ]), + }); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:00.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:00:00.000Z'); + }); + + it('does not resume member task intervals from a stale heartbeat older than offline status', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z')); + const resumeSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + const svc = new TeamProvisioningService(); + const teamName = 'spawn-runtime-stale-heartbeat-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([ + [ + 'alice', + { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T10:04:00.000Z', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastHeartbeatAt: '2026-05-02T10:00:00.000Z', + }, + ], + ]), + }); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:30.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:05:00.000Z'); + }); + + it('does not resume member task intervals from stale direct runtime evidence', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:05:00.000Z')); + const resumeSpy = vi + .spyOn(TeamTaskActivityIntervalService.prototype, 'resumeActiveIntervalsForMember') + .mockReturnValue({ changedTasks: 0 }); + const svc = new TeamProvisioningService(); + const teamName = 'spawn-runtime-stale-direct-evidence-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + const previous = { + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T10:04:00.000Z', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }; + const next = { + ...previous, + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-05-02T10:03:00.000Z', + runtimeAlive: true, + bootstrapConfirmed: true, + }; + + (svc as any).syncMemberTaskActivityForRuntimeTransition( + run, + 'alice', + previous, + next, + '2026-05-02T10:00:30.000Z' + ); + + expect(resumeSpy).toHaveBeenCalledWith(teamName, 'alice', '2026-05-02T10:05:00.000Z'); + }); + it('retries the owner status request when a member-spawn change lands while it is building', async () => { const svc = new TeamProvisioningService(); const teamName = 'spawn-cache-owner-retry-team'; @@ -3079,6 +3292,179 @@ describe('TeamProvisioningService', () => { ); }); + it('projects a pending restart as bootstrap-pending in finished launch snapshots without mutating live state', () => { + const requestedAt = new Date().toISOString(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + firstSpawnAcceptedAt: requestedAt, + runtimeDiagnostic: undefined, + runtimeDiagnosticSeverity: undefined, + }), + ], + ]), + }); + run.isLaunch = true; + run.provisioningComplete = true; + run.pendingMemberRestarts.set('bob', { + requestedAt, + desired: { + name: 'bob', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + }, + }); + const svc = new TeamProvisioningService(); + + const projected = (svc as any).buildRuntimeSpawnStatusRecord(run); + + expect(projected.bob).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + runtimeDiagnostic: 'Manual restart is already in progress; waiting for teammate bootstrap.', + runtimeDiagnosticSeverity: 'info', + }); + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + hardFailure: false, + }); + }); + + it('does not sync a stale never-spawned launch snapshot over a pending restart', () => { + const requestedAt = new Date().toISOString(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + firstSpawnAcceptedAt: requestedAt, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + }), + ], + ]), + }); + run.pendingMemberRestarts.set('bob', { + requestedAt, + desired: { + name: 'bob', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + }, + }); + const snapshot = createPersistedLaunchSnapshot({ + teamName: run.teamName, + expectedMembers: ['bob'], + launchPhase: 'finished', + members: { + bob: { + name: 'bob', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: new Date().toISOString(), + }, + }, + }); + const svc = new TeamProvisioningService(); + + (svc as any).syncRunMemberSpawnStatusesFromSnapshot(run, snapshot); + + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + }); + }); + + it('does not mark a pending restart as failed during bootstrap cleanup projection', () => { + const requestedAt = new Date().toISOString(); + const run = createMemberSpawnRun({ + teamName: 'codex-team', + expectedMembers: ['alice', 'bob'], + memberSpawnStatuses: new Map([ + [ + 'alice', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + }), + ], + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + firstSpawnAcceptedAt: requestedAt, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + }), + ], + ]), + }); + run.pendingMemberRestarts.set('bob', { + requestedAt, + desired: { + name: 'bob', + providerId: 'codex', + model: 'gpt-5.2', + effort: 'medium', + }, + }); + const svc = new TeamProvisioningService(); + + (svc as any).markUnconfirmedBootstrapMembersFailed(run, 'launch cleanup requested', { + cleanupRequested: true, + }); + + expect(run.memberSpawnStatuses.get('alice')).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'launch cleanup requested', + }); + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'spawning', + launchState: 'starting', + agentToolAccepted: false, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + }); + }); + it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => { const teamName = 'forge-labs-10'; const teamDir = path.join(tempTeamsBase, teamName); @@ -5582,6 +5968,294 @@ describe('TeamProvisioningService', () => { expect(ledgerEnvelope.data[0].nextAttemptAt).toBeTruthy(); }); + it('materializes plain-text fallback after OpenCode message_send tool errors', async () => { + const svc = new TeamProvisioningService(); + const taskRef = { + taskId: 'task-tool-error-fallback', + displayId: 'toolerr1', + teamName: 'team-a', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'tool_error', + deliveredUserMessageId: 'oc-user-tool-error', + assistantMessageId: 'oc-assistant-tool-error', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send', + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: 'GAUNTLET_CONCURRENT_TOM_OK_1', + reason: 'message_send_tool_error_without_visible_reply_proof', + }, + diagnostics: ['OpenCode tool failed without output'], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Concurrent check. Reply to user with GAUNTLET_CONCURRENT_TOM_OK_1.', + messageId: 'msg-tool-error-fallback', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_plain_text', + visibleReplyCorrelation: 'plain_assistant_text', + diagnostics: expect.arrayContaining([ + 'opencode_message_send_tool_error_plain_text_reply_materialized', + 'opencode_plain_text_reply_materialized_to_user_inbox', + ]), + }); + + const userInbox = JSON.parse( + await fsPromises.readFile( + path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), + 'utf8' + ) + ) as Array>; + expect(userInbox).toHaveLength(1); + expect(userInbox[0]).toMatchObject({ + from: 'bob', + to: 'user', + text: 'GAUNTLET_CONCURRENT_TOM_OK_1', + relayOfMessageId: 'msg-tool-error-fallback', + source: 'runtime_delivery', + taskRefs: [taskRef], + }); + }); + + it('observes OpenCode message_send tool errors quickly before retrying duplicate prompts', async () => { + const svc = new TeamProvisioningService(); + const taskRef = { + taskId: 'task-tool-error-observe-first', + displayId: 'obsfirst', + teamName: 'team-a', + }; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before-tool-error', + responseObservation: { + state: 'tool_error', + deliveredUserMessageId: 'oc-user-tool-error-observe', + assistantMessageId: 'oc-assistant-tool-error-observe', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send-observe', + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'message_send_tool_error_without_visible_reply_proof', + }, + diagnostics: ['OpenCode tool failed without output'], + })); + const observeMessageDelivery = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + responseObservation: { + state: 'responded_plain_text', + deliveredUserMessageId: 'oc-user-tool-error-observe', + assistantMessageId: 'oc-assistant-plain-fallback', + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: 'call-message-send-observe', + visibleReplyMessageId: null, + visibleReplyCorrelation: 'plain_assistant_text', + latestAssistantPreview: 'GAUNTLET_OBSERVE_FIRST_OK_1', + reason: 'assistant_replied_with_plain_text', + }, + diagnostics: ['Observed OpenCode plain-text fallback after message_send tool error'], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery, + } as any, + ]) + ); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.', + messageId: 'msg-tool-error-observe-first', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'tool_error', + reason: 'tool_error_without_required_delivery_proof', + }); + + const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: tempTeamsBase, + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + fileName: 'opencode-prompt-delivery-ledger.json', + }); + const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as { + data: Array<{ nextAttemptAt: string | null }>; + }; + const nextAttemptAt = ledgerEnvelope.data[0]?.nextAttemptAt; + expect(nextAttemptAt).toBeTruthy(); + const delayMs = Date.parse(nextAttemptAt!) - Date.now(); + expect(delayMs).toBeGreaterThanOrEqual(0); + expect(delayMs).toBeLessThanOrEqual(5_000); + + ledgerEnvelope.data[0]!.nextAttemptAt = '2000-01-01T00:00:00.000Z'; + await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8'); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Reply to user with GAUNTLET_OBSERVE_FIRST_OK_1.', + messageId: 'msg-tool-error-observe-first', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + responsePending: false, + responseState: 'responded_plain_text', + visibleReplyCorrelation: 'plain_assistant_text', + }); + + const userInbox = JSON.parse( + await fsPromises.readFile( + path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), + 'utf8' + ) + ) as Array>; + expect(userInbox).toHaveLength(1); + expect(userInbox[0]).toMatchObject({ + from: 'bob', + to: 'user', + text: 'GAUNTLET_OBSERVE_FIRST_OK_1', + relayOfMessageId: 'msg-tool-error-observe-first', + source: 'runtime_delivery', + taskRefs: [taskRef], + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledTimes(1); + expect(observeMessageDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'msg-tool-error-observe-first', + prePromptCursor: 'cursor-before-tool-error', + }) + ); + }); + it('treats OpenCode send bridge timeouts as acceptance-unknown observe-first records', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -5876,6 +6550,242 @@ describe('TeamProvisioningService', () => { expect(sendMessageToMember).not.toHaveBeenCalled(); }); + it('inherits taskRefs from the OpenCode delivery ledger for exact visible replies', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Here is the concrete answer for #abcd1234.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-user-task-1', + relayOfMessageId: 'msg-task-refs-1', + source: 'runtime_delivery', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer for #abcd1234.', + messageId: 'msg-task-refs-1', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + visibleReplyMessageId: 'reply-user-task-1', + visibleReplyCorrelation: 'relayOfMessageId', + }); + + const userInbox = JSON.parse( + await fsPromises.readFile(path.join(inboxDir, 'user.json'), 'utf8') + ) as Array>; + expect(userInbox[0]).toMatchObject({ + messageId: 'reply-user-task-1', + taskRefs: [taskRef], + }); + expect(sendMessageToMember).not.toHaveBeenCalled(); + }); + + it('repairs OpenCode visible replies that used a wrong relayOfMessageId but returned a messageId', async () => { + const svc = new TeamProvisioningService(); + const taskRef = { taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }; + const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes'); + const sendMessageToMember = vi.fn(async (input: Record) => { + await fsPromises.mkdir(inboxDir, { recursive: true }); + await fsPromises.writeFile( + path.join(inboxDir, 'user.json'), + `${JSON.stringify( + [ + { + from: 'bob', + to: 'user', + text: 'Here is the concrete answer for #abcd1234.', + timestamp: '2026-04-25T10:00:03.000Z', + read: false, + messageId: 'reply-wrong-relay-1', + relayOfMessageId: 'hallucinated-inbound-id', + source: 'runtime_delivery', + taskRefs: [taskRef], + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + return { + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + prePromptCursor: 'cursor-before', + responseObservation: { + state: 'responded_visible_message', + deliveredUserMessageId: 'oc-user-1', + assistantMessageId: 'oc-assistant-1', + toolCallNames: ['message_send'], + visibleMessageToolCallId: 'call-1', + visibleReplyMessageId: 'reply-wrong-relay-1', + visibleReplyCorrelation: 'direct_child_message_send', + visibleReplyMissingRelayOfMessageId: true, + latestAssistantPreview: null, + reason: 'visible_reply_missing_relayOfMessageId', + }, + diagnostics: [], + }; + }); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + await writeDefaultBobOpenCodeBootstrapEvidence(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'Please answer for #abcd1234.', + messageId: 'msg-wrong-relay-1', + replyRecipient: 'user', + actionMode: 'ask', + taskRefs: [taskRef], + source: 'watcher', + inboxTimestamp: '2026-04-25T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: false, + responseState: 'responded_visible_message', + visibleReplyMessageId: 'reply-wrong-relay-1', + visibleReplyCorrelation: 'relayOfMessageId', + diagnostics: expect.arrayContaining([ + 'opencode_visible_reply_recovered_by_observed_message_id', + 'opencode_visible_reply_relayOfMessageId_repaired', + ]), + }); + + const userInbox = JSON.parse( + await fsPromises.readFile(path.join(inboxDir, 'user.json'), 'utf8') + ) as Array>; + expect(userInbox).toHaveLength(1); + expect(userInbox[0]).toMatchObject({ + messageId: 'reply-wrong-relay-1', + relayOfMessageId: 'msg-wrong-relay-1', + taskRefs: [taskRef], + }); + expect(sendMessageToMember).toHaveBeenCalledTimes(1); + }); + it('accepts observed visible OpenCode user replies for lead-delegated inbox messages', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -18736,14 +19646,20 @@ describe('TeamProvisioningService', () => { ); await expect( - (svc as any).runMemberLifecycleOperation('same-team', 'bob', 'manual_restart', async () => - 'bob-ok' + (svc as any).runMemberLifecycleOperation( + 'same-team', + 'bob', + 'manual_restart', + async () => 'bob-ok' ) ).resolves.toBe('bob-ok'); await expect( - (svc as any).runMemberLifecycleOperation('other-team', 'alice', 'manual_restart', async () => - 'other-ok' + (svc as any).runMemberLifecycleOperation( + 'other-team', + 'alice', + 'manual_restart', + async () => 'other-ok' ) ).resolves.toBe('other-ok'); diff --git a/test/main/services/team/TeamTaskActivityIntervalService.test.ts b/test/main/services/team/TeamTaskActivityIntervalService.test.ts index 33c1ee7d..bdc3072e 100644 --- a/test/main/services/team/TeamTaskActivityIntervalService.test.ts +++ b/test/main/services/team/TeamTaskActivityIntervalService.test.ts @@ -30,6 +30,7 @@ describe('TeamTaskActivityIntervalService', () => { afterEach(async () => { vi.useRealTimers(); + vi.restoreAllMocks(); setClaudeBasePathOverride(null); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); @@ -112,15 +113,319 @@ describe('TeamTaskActivityIntervalService', () => { ]); }); - it('resumes active work and current review intervals for the selected member', async () => { + it('materializes closed intervals for legacy active history timers on pause', async () => { await writeTask('alpha', { - id: 'task-1', + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:10:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:05:00.000Z', + completedAt: '2026-05-08T10:10:00.000Z', + }, + ]); + }); + + it('does not backfill legacy history time once persisted intervals exist', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:20:00.000Z' }], + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:25:00.000Z' }], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:30:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:20:00.000Z', completedAt: '2026-05-08T10:30:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:25:00.000Z', + completedAt: '2026-05-08T10:30:00.000Z', + }, + ]); + }); + + it('backfills the active legacy cycle when only older persisted intervals exist', async () => { + await writeTask('alpha', { + id: 'work-task', subject: 'Build', owner: 'bob', status: 'in_progress', workIntervals: [ { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, ], + historyEvents: [ + { + id: 'event-work-started-old', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + { + id: 'event-work-paused-old', + type: 'status_changed', + from: 'in_progress', + to: 'completed', + timestamp: '2026-05-08T10:05:00.000Z', + }, + { + id: 'event-work-started-current', + type: 'status_changed', + from: 'completed', + to: 'in_progress', + timestamp: '2026-05-08T10:20:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + reviewIntervals: [ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T10:05:00.000Z', + }, + ], + historyEvents: [ + { + id: 'event-review-started-old', + type: 'review_started', + timestamp: '2026-05-08T10:00:00.000Z', + actor: 'alice', + }, + { + id: 'event-review-approved-old', + type: 'review_approved', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'alice', + }, + { + id: 'event-review-started-current', + type: 'review_started', + timestamp: '2026-05-08T10:20:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:30:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + { startedAt: '2026-05-08T10:20:00.000Z', completedAt: '2026-05-08T10:30:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T10:05:00.000Z', + }, + { + reviewer: 'alice', + startedAt: '2026-05-08T10:20:00.000Z', + completedAt: '2026-05-08T10:30:00.000Z', + }, + ]); + }); + + it('ignores malformed persisted intervals when materializing legacy history timers', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ completedAt: '2026-05-08T10:01:00.000Z' }], + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + reviewIntervals: [{ startedAt: '2026-05-08T10:04:00.000Z' }], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:10:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { completedAt: '2026-05-08T10:01:00.000Z' }, + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { startedAt: '2026-05-08T10:04:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' }, + { + reviewer: 'alice', + startedAt: '2026-05-08T10:05:00.000Z', + completedAt: '2026-05-08T10:10:00.000Z', + }, + ]); + }); + + it('normalizes invalid completedAt values before renderer filtering can fall back to history', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:02:00.000Z', completedAt: 'bad-date' }], + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + reviewIntervals: [ + { reviewer: 'alice', startedAt: '2026-05-08T10:06:00.000Z', completedAt: 456 }, + ], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:10:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:02:00.000Z', completedAt: '2026-05-08T10:02:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:06:00.000Z', + completedAt: '2026-05-08T10:06:00.000Z', + }, + ]); + }); + + it('resumes active work and current review intervals for the selected member', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, + ], + historyEvents: [], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'alice', + status: 'completed', reviewIntervals: [ { reviewer: 'bob', @@ -143,14 +448,15 @@ describe('TeamTaskActivityIntervalService', () => { 'bob', '2026-05-08T10:20:00.000Z' ); - const task = await readTask('alpha', 'task-1'); + const workTask = await readTask('alpha', 'work-task'); + const reviewTask = await readTask('alpha', 'review-task'); - expect(result.changedTasks).toBe(1); - expect(task.workIntervals).toEqual([ + expect(result.changedTasks).toBe(2); + expect(workTask.workIntervals).toEqual([ { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' }, { startedAt: '2026-05-08T10:20:00.000Z' }, ]); - expect(task.reviewIntervals).toEqual([ + expect(reviewTask.reviewIntervals).toEqual([ { reviewer: 'bob', startedAt: '2026-05-08T10:06:00.000Z', @@ -160,6 +466,131 @@ describe('TeamTaskActivityIntervalService', () => { ]); }); + it('does not resume intervals before the active work or review start', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:05:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'alice', + status: 'completed', + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:06:00.000Z', + actor: 'bob', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:00:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:05:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { reviewer: 'bob', startedAt: '2026-05-08T10:06:00.000Z' }, + ]); + }); + + it('resumes active intervals when existing open-like persisted intervals are malformed', async () => { + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:10:00.000Z', completedAt: '' }], + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'alice', + status: 'completed', + reviewIntervals: [ + { reviewer: 'bob', startedAt: '2026-05-08T10:11:00.000Z', completedAt: 123 }, + ], + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:05:00.000Z', + actor: 'bob', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember( + 'alpha', + 'bob', + '2026-05-08T10:20:00.000Z' + ); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:10:00.000Z', completedAt: '' }, + { startedAt: '2026-05-08T10:20:00.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { reviewer: 'bob', startedAt: '2026-05-08T10:11:00.000Z', completedAt: 123 }, + { reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' }, + ]); + }); + + it('does not resume review intervals for non-completed tasks with stale review history', async () => { + await writeTask('alpha', { + id: 'task-1', + subject: 'Build', + owner: 'bob', + status: 'pending', + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:06:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember( + 'alpha', + 'alice', + '2026-05-08T10:20:00.000Z' + ); + const task = await readTask('alpha', 'task-1'); + + expect(result.changedTasks).toBe(0); + expect(task.reviewIntervals).toBeUndefined(); + }); + it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z')); @@ -219,6 +650,84 @@ describe('TeamTaskActivityIntervalService', () => { ]); }); + it('repairs legacy active history timers into closed intervals after a crash', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z')); + await writeTask('alpha', { + id: 'work-task', + subject: 'Build', + owner: 'bob', + status: 'in_progress', + historyEvents: [ + { + id: 'event-work-started', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-08T10:00:00.000Z', + }, + ], + }); + await writeTask('alpha', { + id: 'review-task', + subject: 'Review', + owner: 'bob', + status: 'completed', + historyEvents: [ + { + id: 'event-review-started', + type: 'review_started', + timestamp: '2026-05-08T10:10:00.000Z', + actor: 'alice', + }, + ], + }); + + const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', { + version: 2, + teamName: 'alpha', + updatedAt: '2026-05-08T10:31:00.000Z', + launchPhase: 'active', + expectedMembers: ['bob', 'alice'], + members: { + bob: { + name: 'bob', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeLastSeenAt: '2026-05-08T10:30:00.000Z', + lastEvaluatedAt: '2026-05-08T10:31:00.000Z', + }, + alice: { + name: 'alice', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastHeartbeatAt: '2026-05-08T10:20:00.000Z', + lastEvaluatedAt: '2026-05-08T10:31:00.000Z', + }, + }, + summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 }, + teamLaunchState: 'clean_success', + }); + + expect(result.changedTasks).toBe(2); + expect((await readTask('alpha', 'work-task')).workIntervals).toEqual([ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' }, + ]); + expect((await readTask('alpha', 'review-task')).reviewIntervals).toEqual([ + { + reviewer: 'alice', + startedAt: '2026-05-08T10:10:00.000Z', + completedAt: '2026-05-08T10:20:05.000Z', + }, + ]); + }); + it('repairs stale open intervals near their start time when no runtime evidence exists', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z')); @@ -247,4 +756,16 @@ describe('TeamTaskActivityIntervalService', () => { }, ]); }); + + it('reports failure when task files cannot be scanned', async () => { + await fs.mkdir(path.join(tempDir, 'tasks'), { recursive: true }); + await fs.writeFile(path.join(tempDir, 'tasks', 'alpha'), 'not a directory', 'utf8'); + + const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam( + 'alpha', + '2026-05-08T10:10:00.000Z' + ); + + expect(result).toEqual({ changedTasks: 0, failed: true }); + }); }); diff --git a/test/main/services/team/TeamTaskWriter.test.ts b/test/main/services/team/TeamTaskWriter.test.ts index 2c15e0dc..3ff59855 100644 --- a/test/main/services/team/TeamTaskWriter.test.ts +++ b/test/main/services/team/TeamTaskWriter.test.ts @@ -134,6 +134,28 @@ describe('TeamTaskWriter', () => { }); }); + it('opens a new work interval when the previous completedAt is malformed', async () => { + hoisted.files.set( + taskPath, + JSON.stringify({ + id: '12', + subject: 'task', + owner: 'alice', + status: 'pending', + workIntervals: [{ startedAt: '2026-05-02T10:00:00.000Z', completedAt: '' }], + }) + ); + + await writer.updateStatus('my-team', '12', 'in_progress'); + + const persisted = JSON.parse(hoisted.files.get(taskPath) ?? '{}') as { + workIntervals?: { startedAt?: string; completedAt?: string }[]; + }; + expect(persisted.workIntervals).toHaveLength(2); + expect(persisted.workIntervals?.[0]?.completedAt).toBe(''); + expect(persisted.workIntervals?.[1]?.completedAt).toBeUndefined(); + }); + it('throws when verify detects conflicting status', async () => { hoisted.files.set( taskPath, @@ -217,7 +239,13 @@ describe('TeamTaskWriter', () => { subject: 'task', status: 'pending', historyEvents: [ - { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z', actor: 'user' }, + { + type: 'task_created', + id: 'ev1', + status: 'pending', + timestamp: '2024-01-01T00:00:00.000Z', + actor: 'user', + }, ], }) ); @@ -264,8 +292,19 @@ describe('TeamTaskWriter', () => { subject: 'task', status: 'in_progress', historyEvents: [ - { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, - { type: 'status_changed', id: 'ev2', from: 'pending', to: 'in_progress', timestamp: '2024-01-01T00:01:00.000Z' }, + { + type: 'task_created', + id: 'ev1', + status: 'pending', + timestamp: '2024-01-01T00:00:00.000Z', + }, + { + type: 'status_changed', + id: 'ev2', + from: 'pending', + to: 'in_progress', + timestamp: '2024-01-01T00:01:00.000Z', + }, ], }) ); @@ -291,8 +330,19 @@ describe('TeamTaskWriter', () => { status: 'deleted', deletedAt: '2024-01-01T00:02:00.000Z', historyEvents: [ - { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, - { type: 'status_changed', id: 'ev2', from: 'pending', to: 'deleted', timestamp: '2024-01-01T00:02:00.000Z' }, + { + type: 'task_created', + id: 'ev1', + status: 'pending', + timestamp: '2024-01-01T00:00:00.000Z', + }, + { + type: 'status_changed', + id: 'ev2', + from: 'pending', + to: 'deleted', + timestamp: '2024-01-01T00:02:00.000Z', + }, ], }) ); @@ -342,7 +392,12 @@ describe('TeamTaskWriter', () => { owner: 'alice', status: 'pending', historyEvents: [ - { type: 'task_created', id: 'ev1', status: 'pending', timestamp: '2024-01-01T00:00:00.000Z' }, + { + type: 'task_created', + id: 'ev1', + status: 'pending', + timestamp: '2024-01-01T00:00:00.000Z', + }, ], }) ); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts index 8d166bde..2317c57c 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'; import { TeamTaskStallPolicy } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallPolicy'; -import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes'; +import type { + TeamTaskStallExactRow, + TeamTaskStallSnapshot, +} from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes'; import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; import type { ParsedMessage } from '../../../../../src/main/types'; import type { TeamTask } from '../../../../../src/shared/types'; @@ -105,6 +108,33 @@ function createSnapshot(overrides: Partial): TeamTaskStal describe('TeamTaskStallPolicy', () => { const policy = new TeamTaskStallPolicy(); + it('does not treat malformed empty completedAt as an open work interval', () => { + const task: TeamTask = { + id: 'task-closed-empty', + displayId: 'feed0000', + subject: 'Malformed closed interval', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z', completedAt: '' }], + }; + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:30:00.000Z'), + task, + snapshot: createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + }), + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-closed-empty', + skipReason: 'no_open_work_interval', + }); + }); + it('alerts for work stall after turn ended and threshold elapsed', () => { const task: TeamTask = { id: 'task-a', diff --git a/test/main/services/team/teamTaskActiveState.test.ts b/test/main/services/team/teamTaskActiveState.test.ts index 2cb34fe4..5f679705 100644 --- a/test/main/services/team/teamTaskActiveState.test.ts +++ b/test/main/services/team/teamTaskActiveState.test.ts @@ -326,4 +326,36 @@ describe('selectCurrentActiveTeamTask', () => { expect(selected?.id).toBe('task-a'); }); + + it('does not treat malformed empty completedAt as an open work interval', () => { + const tasks: TeamTaskWithKanban[] = [ + { + id: 'task-a', + displayId: '1', + subject: 'Malformed closed interval', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T13:00:00.000Z', completedAt: '' }], + historyEvents: [ + { + id: 'event-a', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + timestamp: '2026-05-06T10:00:00.000Z', + }, + ], + }, + { + id: 'task-b', + displayId: '2', + subject: 'Real active task', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-06T11:00:00.000Z' }], + }, + ]; + + const selected = selectCurrentActiveTeamTask(tasks); + + expect(selected?.id).toBe('task-b'); + }); }); diff --git a/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx index 30bc6aba..6029a85a 100644 --- a/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx +++ b/test/renderer/components/team/members/MemberLogStreamSection.fixture-e2e.test.tsx @@ -10,6 +10,8 @@ import { GetMemberLogStreamUseCase } from '../../../../../src/features/member-lo import { type MemberLogStreamRequestOptions, type MemberLogStreamResponse, + type MemberRuntimeLogTailOptions, + type MemberRuntimeLogTailResponse, } from '../../../../../src/features/member-log-stream/contracts'; import { ClaudeMemberTranscriptStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/ClaudeMemberTranscriptStreamSource'; import { OpenCodeMemberRuntimeStreamSource } from '../../../../../src/features/member-log-stream/main/adapters/output/sources/OpenCodeMemberRuntimeStreamSource'; @@ -42,6 +44,14 @@ const apiState = { ) => Promise >(), setMemberLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise>(), + getMemberRuntimeLogTail: + vi.fn< + ( + teamName: string, + memberName: string, + options: MemberRuntimeLogTailOptions + ) => Promise + >(), onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(), }; @@ -53,6 +63,9 @@ vi.mock('@renderer/api', () => ({ setMemberLogStreamTracking: ( ...args: Parameters ) => apiState.setMemberLogStreamTracking(...args), + getMemberRuntimeLogTail: ( + ...args: Parameters + ) => apiState.getMemberRuntimeLogTail(...args), }, teams: { onTeamChange: (...args: Parameters) => @@ -266,6 +279,7 @@ describe('MemberLogStreamSection real fixture e2e', () => { document.body.innerHTML = ''; apiState.getMemberLogStream.mockReset(); apiState.setMemberLogStreamTracking.mockReset(); + apiState.getMemberRuntimeLogTail.mockReset(); apiState.onTeamChange.mockReset(); vi.unstubAllGlobals(); await Promise.all( @@ -280,6 +294,13 @@ describe('MemberLogStreamSection real fixture e2e', () => { stubMatchMedia(); apiState.onTeamChange.mockImplementation(() => () => undefined); apiState.setMemberLogStreamTracking.mockResolvedValue(undefined); + apiState.getMemberRuntimeLogTail.mockResolvedValue({ + kind: 'stdout', + content: 'process stdout line', + truncated: false, + bytesRead: 19, + missing: false, + }); const { useCase, getOpenCodeTranscript, findRecentMemberLogFileRefsByMember } = await createFixtureUseCase(); @@ -375,4 +396,75 @@ describe('MemberLogStreamSection real fixture e2e', () => { expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, true); expect(apiState.setMemberLogStreamTracking).toHaveBeenCalledWith(TEAM_NAME, false); }); + + it('loads bounded process runtime logs after switching the Logs UI to Process', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + stubMatchMedia(); + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.setMemberLogStreamTracking.mockResolvedValue(undefined); + apiState.getMemberLogStream.mockResolvedValue({ + participants: [], + defaultFilter: 'all', + segments: [], + source: 'member_empty', + coverage: [], + warnings: [], + truncated: false, + generatedAt: GENERATED_AT, + metadata: { + scannedTranscriptFileCount: 0, + includedTranscriptFileCount: 0, + droppedSegmentCount: 0, + droppedChunkCount: 0, + droppedMessageCount: 0, + }, + }); + apiState.getMemberRuntimeLogTail.mockResolvedValue({ + kind: 'stdout', + content: 'process stdout line', + truncated: false, + bytesRead: 19, + missing: false, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(MemberLogStreamSection, { + teamName: TEAM_NAME, + member: createMember(), + }) + ) + ); + await flushMicrotasks(); + }); + + const processButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Process' + ) as HTMLButtonElement | undefined; + expect(processButton).toBeTruthy(); + + await act(async () => { + processButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushAsyncWork(); + }); + + await waitForText(host, (content) => content.includes('process stdout line')); + expect(apiState.getMemberRuntimeLogTail).toHaveBeenCalledWith(TEAM_NAME, MEMBER_NAME, { + kind: 'stdout', + maxBytes: 128 * 1024, + forceRefresh: true, + }); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberLogsTab.test.ts b/test/renderer/components/team/members/MemberLogsTab.test.ts new file mode 100644 index 00000000..ece58644 --- /dev/null +++ b/test/renderer/components/team/members/MemberLogsTab.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { filterChunksByWorkIntervals } from '@renderer/components/team/members/MemberLogsTab'; + +function makeChunk(id: string, start: string, end: string) { + return { + id, + startTime: new Date(start), + endTime: new Date(end), + } as never; +} + +describe('MemberLogsTab work interval filtering', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not treat malformed empty completedAt as an open interval', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z')); + const chunks = [ + makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'), + makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'), + ]; + + const filtered = filterChunksByWorkIntervals(chunks, [ + { startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' }, + ]); + + expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start']); + }); + + it('clamps completedAt before startedAt to a closed start window', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z')); + const chunks = [ + makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'), + makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'), + ]; + + const filtered = filterChunksByWorkIntervals(chunks, [ + { + startedAt: '2026-05-08T10:00:00.000Z', + completedAt: '2026-05-08T09:59:00.000Z', + }, + ]); + + expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start']); + }); + + it('keeps undefined completedAt as the only open interval shape', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-08T11:00:00.000Z')); + const chunks = [ + makeChunk('near-start', '2026-05-08T09:59:50.000Z', '2026-05-08T10:00:05.000Z'), + makeChunk('late', '2026-05-08T10:30:00.000Z', '2026-05-08T10:30:05.000Z'), + ]; + + const filtered = filterChunksByWorkIntervals(chunks, [ + { startedAt: '2026-05-08T10:00:00.000Z' }, + ]); + + expect(filtered?.map((chunk) => chunk.id)).toEqual(['near-start', 'late']); + }); +}); diff --git a/test/renderer/utils/memberActivityTimer.test.ts b/test/renderer/utils/memberActivityTimer.test.ts index 054753c9..48e1f288 100644 --- a/test/renderer/utils/memberActivityTimer.test.ts +++ b/test/renderer/utils/memberActivityTimer.test.ts @@ -116,6 +116,36 @@ describe('memberActivityTimer', () => { ).toBeNull(); }); + it('does not treat invalid empty completedAt values as active work or review intervals', () => { + const workTask: TeamTaskWithKanban = { + ...baseTask, + workIntervals: [{ startedAt: '2026-05-07T09:10:00.000Z', completedAt: '' }], + }; + expect( + deriveWorkActivityTimerAnchor(workTask, { + teamName: 'alpha', + memberName: 'bob', + }) + ).toBeNull(); + + const reviewTask: TeamTaskWithKanban = { + ...baseTask, + status: 'completed', + reviewState: 'review', + kanbanColumn: 'review', + reviewer: 'alice', + reviewIntervals: [ + { reviewer: 'alice', startedAt: '2026-05-07T09:30:00.000Z', completedAt: '' }, + ], + }; + expect( + deriveReviewActivityTimerAnchor(reviewTask, { + teamName: 'alpha', + memberName: 'alice', + }) + ).toBeNull(); + }); + it('anchors review timers only after the reviewer actually starts review', () => { const assignedOnly: TeamTaskWithKanban = { ...baseTask, diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index fbc26bf0..a8678cb3 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -864,6 +864,23 @@ describe('memberHelpers spawn-aware presence', () => { expect(title).not.toContain('non_visible_tool_without_task_progress'); }); + it('formats missing taskRefs advisory reasons before showing them in titles', () => { + const title = getMemberRuntimeAdvisoryTitle( + { + kind: 'api_error', + observedAt: '2026-04-07T09:00:00.000Z', + reasonCode: 'protocol_proof_missing', + message: 'visible_reply_missing_task_refs', + }, + 'opencode' + ); + + expect(title).toContain( + 'OpenCode created a reply without the required taskRefs metadata.' + ); + expect(title).not.toContain('visible_reply_missing_task_refs'); + }); + it('renders Codex native timeout separately from network errors', () => { const advisory = { kind: 'api_error' as const, diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index f538a623..58123abc 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -98,4 +98,25 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode used tools, but did not create a visible reply or task progress proof.' ); }); + + it('surfaces missing taskRefs proof as a readable failure', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-taskrefs-required', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'responded_visible_message', + ledgerStatus: 'failed_terminal', + reason: 'visible_reply_missing_task_refs', + diagnostics: ['visible_reply_missing_task_refs'], + }, + }); + + expect(diagnostics.warning).toBe( + '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.' + ); + }); }); diff --git a/test/shared/utils/taskWorkDuration.test.ts b/test/shared/utils/taskWorkDuration.test.ts index 3e6d60b7..5157415a 100644 --- a/test/shared/utils/taskWorkDuration.test.ts +++ b/test/shared/utils/taskWorkDuration.test.ts @@ -53,6 +53,39 @@ describe('taskWorkDuration', () => { }); }); + it('does not treat empty completedAt strings as running implementation time', () => { + const duration = calculateTaskImplementationDuration( + { + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' }], + }, + Date.parse('2026-05-08T10:30:00.000Z') + ); + + expect(duration).toEqual({ + elapsedMs: 0, + hasRunningInterval: false, + countedIntervalCount: 0, + }); + + expect( + calculateTaskImplementationEventDuration( + { + status: 'in_progress', + workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '' }], + }, + { + id: 'event-started', + timestamp: '2026-05-08T10:00:00.000Z', + type: 'status_changed', + from: 'pending', + to: 'in_progress', + }, + Date.parse('2026-05-08T10:30:00.000Z') + ) + ).toBeNull(); + }); + it('merges overlapping intervals to avoid double counting malformed data', () => { const duration = calculateTaskImplementationDuration( {