From bbafedf06aebf1af4badc0b21004cf0a1511651c Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 17:07:21 +0300 Subject: [PATCH] fix: stabilize OpenCode team runtime delivery --- .../services/team/TeamProvisioningService.ts | 362 ++++++++++++-- .../bridge/OpenCodeBridgeCommandContract.ts | 1 + .../delivery/OpenCodePromptDeliveryLedger.ts | 13 +- .../OpenCodePromptDeliveryWatchdog.ts | 1 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 1 + .../components/team/TeamDetailView.tsx | 48 +- .../team/messages/MessagesPanel.tsx | 450 ++++++++---------- .../components/team/messages/StatusBlock.tsx | 6 +- .../components/team/teamRuntimeDisplayRows.ts | 17 +- .../openCodeRuntimeDeliveryDiagnostics.ts | 3 + src/shared/types/team.ts | 1 + ...ProductionPromptArtifacts.safe-e2e.test.ts | 1 + .../team/OpenCodePromptDeliveryLedger.test.ts | 32 ++ .../team/OpenCodeTeamRuntimeAdapter.test.ts | 1 + .../team/TeamProvisioningService.test.ts | 266 ++++++++++- .../team/teamRuntimeDisplayRows.test.ts | 2 +- ...openCodeRuntimeDeliveryDiagnostics.test.ts | 25 + 17 files changed, 935 insertions(+), 295 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b848d0fe..6bc52e46 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -305,6 +305,9 @@ interface PersistedRuntimeMemberLike { backendType?: string; providerId?: string; cwd?: string; + bootstrapExpectedAfter?: string; + bootstrapProofToken?: string; + bootstrapRuntimeEventsPath?: string; runtimePid?: number; runtimeSessionId?: string; } @@ -338,6 +341,40 @@ interface LaunchStateWriteResult { type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; +const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success'; +const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024; + +function sanitizeRuntimeEventFilePrefix(value: string): string { + return String(value || 'default') + .replace(/[^a-zA-Z0-9]/g, '-') + .toLowerCase(); +} + +function parseRuntimeBootstrapProofDetail(detail: unknown): Record { + if (typeof detail !== 'string' || detail.trim().length === 0) { + return {}; + } + try { + const parsed = JSON.parse(detail) as unknown; + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function getRuntimeBootstrapProofString( + event: Record, + detail: Record, + field: 'source' | 'bootstrapProofToken' +): string | undefined { + const direct = event[field]; + if (typeof direct === 'string' && direct.trim().length > 0) { + return direct.trim(); + } + const nested = detail[field]; + return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined; +} + type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -2658,6 +2695,14 @@ function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean { ); } +function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean { + return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.'; +} + +function isBootstrapInstructionPromptFailureReason(reason?: string): boolean { + return typeof reason === 'string' && isBootstrapInstructionPrompt(reason); +} + function isTmuxNoServerRunningError(error: unknown): boolean { const text = error instanceof Error ? error.message : String(error ?? ''); return ( @@ -2673,7 +2718,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean { isConfigRegistrationFailureReason(reason) || isRegisteredRuntimeMetadataFailureReason(reason) || isOpenCodeBridgeLaunchFailureReason(reason) || - isBootstrapMcpResourceReadFailureReason(reason) + isBootstrapMcpResourceReadFailureReason(reason) || + isBootstrapCheckInTimeoutFailureReason(reason) || + isBootstrapInstructionPromptFailureReason(reason) ); } @@ -6605,9 +6652,34 @@ export class TeamProvisioningService { if (state === 'empty_assistant_turn') { return 'empty_assistant_turn'; } + if (state === 'prompt_delivered_no_assistant_message') { + return 'prompt_delivered_no_assistant_message'; + } return record?.lastReason ?? 'opencode_delivery_response_pending'; } + private normalizeOpenCodeDeliveryResponseObservation( + observation?: NonNullable + ): NonNullable | undefined { + if ( + observation?.state !== 'empty_assistant_turn' || + !observation.deliveredUserMessageId || + observation.assistantMessageId || + observation.latestAssistantPreview?.trim() || + observation.toolCallNames.length > 0 || + observation.visibleMessageToolCallId || + observation.visibleReplyMessageId + ) { + return observation; + } + + return { + ...observation, + state: 'prompt_delivered_no_assistant_message', + reason: 'prompt_delivered_no_assistant_message', + }; + } + private isOpenCodeDeliveryRetryablePendingResponse(input: { ledgerRecord: OpenCodePromptDeliveryLedgerRecord; visibleReply?: OpenCodeVisibleReplyProof | null; @@ -7494,7 +7566,7 @@ export class TeamProvisioningService { lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase() ); trackedSecondaryLanePresent = liveLane != null; - liveSecondaryLaneRunId = liveLane ? trackedRunId : null; + liveSecondaryLaneRunId = liveLane?.runId?.trim() || null; const liveLaneMember = liveLane ? (liveLane.result?.members?.[canonicalMemberName] ?? liveLane.result?.members?.[liveLane.member.name]) @@ -7639,11 +7711,14 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + result.responseObservation + ); return { delivered: result.ok, accepted: result.ok, responsePending: false, - responseState: result.responseObservation?.state, + responseState: responseObservation?.state, ...(result.ok ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), @@ -7880,9 +7955,12 @@ export class TeamProvisioningService { runtimePid: observed.runtimePid, reason: 'opencode_delivery_observe_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + observed.responseObservation + ); ledgerRecord = await ledger.applyObservation({ id: ledgerRecord.id, - responseObservation: observed.responseObservation ?? { + responseObservation: responseObservation ?? { state: observed.ok ? 'not_observed' : 'reconcile_failed', deliveredUserMessageId: null, assistantMessageId: null, @@ -8004,16 +8082,19 @@ export class TeamProvisioningService { runtimePid: result.runtimePid, reason: 'opencode_delivery_runtime_pid_observed', }); + const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( + result.responseObservation + ); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, accepted: result.ok, attempted: true, - responseObservation: result.responseObservation, + responseObservation, sessionId: result.sessionId, prePromptCursor: result.prePromptCursor, diagnostics: result.diagnostics, - reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0], + reason: result.ok ? responseObservation?.reason : result.diagnostics[0], now: nowIso(), }); let proof = await this.applyOpenCodeVisibleDestinationProof({ @@ -8044,7 +8125,7 @@ export class TeamProvisioningService { { accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null } ); } - const responseState = ledgerRecord?.responseState ?? result.responseObservation?.state; + const responseState = ledgerRecord?.responseState ?? responseObservation?.state; const visibleReply = ledgerRecord ? await this.findOpenCodeVisibleReplyByRelayOfMessageId({ teamName, @@ -8139,15 +8220,15 @@ export class TeamProvisioningService { } const responseVisibleReplyMessageId = ledgerRecord?.visibleReplyMessageId ?? - result.responseObservation?.visibleReplyMessageId ?? + responseObservation?.visibleReplyMessageId ?? undefined; const responseVisibleReplyCorrelation = ledgerRecord?.visibleReplyCorrelation ?? - result.responseObservation?.visibleReplyCorrelation ?? + responseObservation?.visibleReplyCorrelation ?? undefined; const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok); const responsePending = - acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation)) + acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation)) ? !readAllowed : false; const pendingReason = @@ -8162,8 +8243,8 @@ export class TeamProvisioningService { : result.diagnostics; return { delivered: result.ok || acceptanceUnknown, - ...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}), - ...(ledgerRecord || result.responseObservation ? { responsePending } : {}), + ...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}), + ...(ledgerRecord || responseObservation ? { responsePending } : {}), ...(acceptanceUnknown ? { acceptanceUnknown: true } : {}), ...(ledgerRecord ? { @@ -11521,7 +11602,8 @@ export class TeamProvisioningService { private confirmMemberSpawnStatusFromTranscript( run: ProvisioningRun, memberName: string, - observedAt: string + observedAt: string, + source: 'transcript' | 'runtime-proof' = 'transcript' ): void { const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); const updatedAt = nowIso(); @@ -11530,7 +11612,7 @@ export class TeamProvisioningService { status: 'online', updatedAt, agentToolAccepted: true, - runtimeAlive: prev.runtimeAlive === true, + runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true, bootstrapConfirmed: true, hardFailure: false, bootstrapStalled: undefined, @@ -11564,7 +11646,13 @@ export class TeamProvisioningService { run.memberSpawnStatuses.set(memberName, next); run.pendingMemberRestarts?.delete(memberName); this.syncMemberLaunchGraceCheck(run, memberName, next); - this.appendMemberBootstrapDiagnostic(run, memberName, 'bootstrap confirmed via transcript'); + this.appendMemberBootstrapDiagnostic( + run, + memberName, + source === 'runtime-proof' + ? 'bootstrap confirmed via runtime proof' + : 'bootstrap confirmed via transcript' + ); if (!this.isCurrentTrackedRun(run)) return; this.emitMemberSpawnChange(run, memberName); if (run.isLaunch) { @@ -13914,12 +14002,26 @@ export class TeamProvisioningService { (current.launchState === 'failed_to_start' && !canClearFailedBootstrap) || current.launchState === 'confirmed_alive' || current.bootstrapConfirmed === true || - current.agentToolAccepted !== true + (current.agentToolAccepted !== true && !canClearFailedBootstrap) ) { continue; } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + run.teamName, + memberName, + current + ); + if (runtimeProofObservedAt) { + this.confirmMemberSpawnStatusFromTranscript( + run, + memberName, + runtimeProofObservedAt, + 'runtime-proof' + ); + continue; + } const transcriptOutcome = await this.findBootstrapTranscriptOutcome( run.teamName, memberName, @@ -22658,6 +22760,19 @@ export class TeamProvisioningService { } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + if ( + current.launchState !== 'failed_to_start' || + isAutoClearableLaunchFailureReason(current.hardFailureReason ?? current.runtimeDiagnostic) + ) { + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + snapshot.teamName, + expected, + current + ); + if (runtimeProofObservedAt) { + return true; + } + } const transcriptOutcome = await this.findBootstrapTranscriptOutcome( snapshot.teamName, expected, @@ -22673,6 +22788,159 @@ export class TeamProvisioningService { return false; } + private resolveBootstrapRuntimeMember( + teamName: string, + memberName: string + ): PersistedRuntimeMemberLike | undefined { + return this.readPersistedRuntimeMembers(teamName).find((member) => { + const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; + return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); + }); + } + + private getBootstrapRuntimeEventsPath( + teamName: string, + memberName: string, + runtimeMember: PersistedRuntimeMemberLike | undefined + ): string { + const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim(); + if (configuredPath) { + return configuredPath; + } + const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName); + return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`); + } + + private async readRuntimeBootstrapProofEvents( + eventsPath: string + ): Promise[]> { + let handle: fs.promises.FileHandle | null = null; + try { + handle = await fs.promises.open(eventsPath, 'r'); + const stat = await handle.stat(); + if (!stat.isFile() || stat.size <= 0) { + return []; + } + const start = Math.max(0, stat.size - BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES); + const buffer = Buffer.alloc(stat.size - start); + if (buffer.length === 0) { + return []; + } + await handle.read(buffer, 0, buffer.length, start); + const lines = buffer.toString('utf8').split('\n'); + if (start > 0) { + lines.shift(); + } + const events: Record[] = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + try { + const parsed = JSON.parse(line) as unknown; + if ( + parsed && + typeof parsed === 'object' && + (parsed as { version?: unknown }).version === 1 && + typeof (parsed as { type?: unknown }).type === 'string' && + typeof (parsed as { timestamp?: unknown }).timestamp === 'string' + ) { + events.push(parsed as Record); + } + } catch { + // Ignore partial lines from concurrently written runtime event files. + } + } + return events; + } catch { + return []; + } finally { + await handle?.close().catch(() => undefined); + } + } + + private isRuntimeBootstrapProofEventValid(input: { + event: Record; + detail: Record; + teamName: string; + memberName: string; + runtimeMember?: PersistedRuntimeMemberLike; + boundaryMs: number; + }): boolean { + const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input; + if (event.type !== 'bootstrap_confirmed') { + return false; + } + if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) { + return false; + } + const source = getRuntimeBootstrapProofString(event, detail, 'source'); + if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) { + return false; + } + const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; + const eventMs = Date.parse(timestamp); + if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) { + return false; + } + const expectedToken = runtimeMember?.bootstrapProofToken?.trim(); + const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken'); + if (expectedToken && eventToken !== expectedToken) { + return false; + } + const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : ''; + const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : ''; + const runtimeName = runtimeMember?.name?.trim() ?? ''; + const runtimeAgentId = runtimeMember?.agentId?.trim() ?? ''; + return ( + (eventAgentName.length > 0 && + (matchesMemberNameOrBase(eventAgentName, memberName) || + (runtimeName.length > 0 && matchesTeamMemberIdentity(eventAgentName, runtimeName)))) || + (eventAgentId.length > 0 && runtimeAgentId.length > 0 && eventAgentId === runtimeAgentId) + ); + } + + private async findBootstrapRuntimeProofObservedAt( + teamName: string, + memberName: string, + member: Pick< + PersistedTeamLaunchMemberState, + 'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason' + > + ): Promise { + const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName); + const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter; + const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN; + if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) { + return null; + } + const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember); + const events = await this.readRuntimeBootstrapProofEvents(eventsPath); + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const event of events) { + const detail = parseRuntimeBootstrapProofDetail(event.detail); + if ( + !this.isRuntimeBootstrapProofEventValid({ + event, + detail, + teamName, + memberName, + runtimeMember, + boundaryMs, + }) + ) { + continue; + } + const timestamp = typeof event.timestamp === 'string' ? event.timestamp : ''; + const timestampMs = Date.parse(timestamp); + if (Number.isFinite(timestampMs) && timestampMs >= latestMs) { + latest = timestamp; + latestMs = timestampMs; + } + } + return latest; + } + private async applyBootstrapTranscriptEvidenceOverlay( snapshot: PersistedTeamLaunchSnapshot | null ): Promise { @@ -22691,15 +22959,30 @@ export class TeamProvisioningService { ) { continue; } + const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic; + const canClearFailedBootstrap = + current.launchState !== 'failed_to_start' || + isAutoClearableLaunchFailureReason(failureReason); + if (!canClearFailedBootstrap) { + continue; + } const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt( + snapshot.teamName, + expected, + current + ); const transcriptOutcome = await this.findBootstrapTranscriptOutcome( snapshot.teamName, expected, Number.isFinite(acceptedAtMs) ? acceptedAtMs : null ); - if (transcriptOutcome?.kind !== 'success') { + const observedAt = + runtimeProofObservedAt ?? + (transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null); + if (!observedAt) { continue; } @@ -22707,9 +22990,13 @@ export class TeamProvisioningService { ...current, agentToolAccepted: true, bootstrapConfirmed: true, + runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true, hardFailure: false, hardFailureReason: undefined, - lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt, + lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt, + lastRuntimeAliveAt: runtimeProofObservedAt + ? (current.lastRuntimeAliveAt ?? observedAt) + : current.lastRuntimeAliveAt, lastEvaluatedAt: nowIso(), sources: { ...(current.sources ?? {}), @@ -22997,6 +23284,8 @@ export class TeamProvisioningService { : null; const acceptedAtMs = current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; + const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic; + const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason); current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; current.livenessKind = runtimeMetadata?.[1].livenessKind; @@ -23019,7 +23308,7 @@ export class TeamProvisioningService { const currentProvesSpawnAcceptance = current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; if ( - isAutoClearableLaunchFailureReason(current.hardFailureReason) && + hadAutoClearableFailure && (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) ) { current.hardFailure = false; @@ -23049,15 +23338,34 @@ export class TeamProvisioningService { current.hardFailure = false; current.hardFailureReason = undefined; } - if (!current.bootstrapConfirmed) { - const transcriptOutcome = await this.findBootstrapTranscriptOutcome( - teamName, - expected, - Number.isFinite(acceptedAtMs) ? acceptedAtMs : null - ); - if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) { + const canApplyBootstrapSuccess = + !heartbeatReason && + (current.launchState !== 'failed_to_start' || + hadAutoClearableFailure || + isAutoClearableLaunchFailureReason( + current.hardFailureReason ?? current.runtimeDiagnostic + )); + if (!current.bootstrapConfirmed && canApplyBootstrapSuccess) { + const runtimeProofObservedAt = !isOpenCodeSecondaryLaneMember + ? await this.findBootstrapRuntimeProofObservedAt(teamName, expected, current) + : null; + const transcriptOutcome = runtimeProofObservedAt + ? null + : await this.findBootstrapTranscriptOutcome( + teamName, + expected, + Number.isFinite(acceptedAtMs) ? acceptedAtMs : null + ); + const bootstrapObservedAt = + runtimeProofObservedAt ?? + (transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null); + if (bootstrapObservedAt && !isOpenCodeSecondaryLaneMember) { current.bootstrapConfirmed = true; - current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt; + current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapObservedAt; + current.runtimeAlive = runtimeProofObservedAt ? true : current.runtimeAlive === true; + current.lastRuntimeAliveAt = runtimeProofObservedAt + ? (current.lastRuntimeAliveAt ?? bootstrapObservedAt) + : current.lastRuntimeAliveAt; current.hardFailure = false; current.hardFailureReason = undefined; if (current.sources) { diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index f941cc6d..ba59a021 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -177,6 +177,7 @@ export type OpenCodeDeliveryResponseState = | 'permission_blocked' | 'tool_error' | 'empty_assistant_turn' + | 'prompt_delivered_no_assistant_message' | 'session_stale' | 'session_error' | 'reconcile_failed'; diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 98180867..d00fb850 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -87,6 +87,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set 'permission_blocked', 'tool_error', 'empty_assistant_turn', + 'prompt_delivered_no_assistant_message', 'session_stale', 'session_error', 'reconcile_failed', @@ -274,7 +275,7 @@ export class OpenCodePromptDeliveryLedgerStore { const responseState = observation?.state ?? (input.accepted ? record.responseState : 'not_observed'); const responded = isOpenCodePromptResponseStateResponded(responseState); - const unanswered = responseState === 'empty_assistant_turn'; + const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState); return { ...record, status: input.accepted @@ -321,7 +322,9 @@ export class OpenCodePromptDeliveryLedgerStore { }): Promise { return await this.updateExisting(input.id, (record) => { const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state); - const unanswered = input.responseObservation.state === 'empty_assistant_turn'; + const unanswered = isOpenCodePromptDeliveryUnansweredResponseState( + input.responseObservation.state + ); return { ...record, status: responded @@ -637,6 +640,12 @@ export function isOpenCodePromptResponseStateResponded( ); } +function isOpenCodePromptDeliveryUnansweredResponseState( + state: OpenCodeDeliveryResponseState +): boolean { + return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message'; +} + export function isOpenCodePromptDeliveryAttemptDue( record: OpenCodePromptDeliveryLedgerRecord, nowMs: number = Date.now() diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts index 7247620b..a20c3885 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts @@ -90,6 +90,7 @@ export function isOpenCodePromptDeliveryRetryableResponseState( ): boolean { return ( state === 'empty_assistant_turn' || + state === 'prompt_delivered_no_assistant_message' || state === 'tool_error' || state === 'reconcile_failed' || state === 'not_observed' || diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index b686585f..ff37753d 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -798,6 +798,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput) ? `Include relayOfMessageId="${input.messageId}" in that message_send call.` : null, '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 call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', 'Do not answer only with plain assistant text when agent-teams_message_send is available.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e6d930bf..1e13a85e 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -345,6 +345,33 @@ interface LeadContextBridgeProps { isThisTabActive: boolean; } +const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = []; + +function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string { + return JSON.stringify( + tasks.map((task) => [ + task.id, + task.displayId ?? '', + task.subject, + task.owner ?? '', + task.reviewer ?? '', + task.status, + task.reviewState ?? '', + task.kanbanColumn ?? '', + ]) + ); +} + +function useStableMessagesPanelTasks( + tasks: TeamTaskWithKanban[] | undefined +): TeamTaskWithKanban[] { + const sourceTasks = tasks ?? EMPTY_MESSAGES_PANEL_TASKS; + const signature = useMemo(() => buildMessagesPanelTasksSignature(sourceTasks), [sourceTasks]); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- sourceTasks identity is gated by render-relevant task fields. + return useMemo(() => sourceTasks, [signature]); +} + // Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet. const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']); @@ -1783,9 +1810,12 @@ export const TeamDetailView = memo(function TeamDetailView({ } }, []); - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); + const handleOpenMessagePanelTask = useCallback( + (task: TeamTaskWithKanban) => { + handleOpenTaskById(task.id); + }, + [handleOpenTaskById] + ); const handleTaskIdClick = useCallback( (taskId: string) => { @@ -2017,21 +2047,22 @@ export const TeamDetailView = memo(function TeamDetailView({ })(); }; + const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks); + const sharedMessagesPanelProps = useMemo( () => ({ teamName, onPositionChange: changeMessagesPanelMode, mountPoint: messagesPanelMountPoint, members: activeMembers, - tasks: data?.tasks ?? [], + tasks: messagesPanelTasks, isTeamAlive: data?.isAlive, timeWindow, - teamSessionIds, currentLeadSessionId: data?.config.leadSessionId, pendingRepliesByMember, onPendingReplyChange: setPendingRepliesByMember, onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, + onTaskClick: handleOpenMessagePanelTask, onCreateTaskFromMessage: handleCreateTaskFromMessage, onReplyToMessage: handleReplyToMessage, onRestartTeam: handleRestartTeam, @@ -2042,17 +2073,16 @@ export const TeamDetailView = memo(function TeamDetailView({ activeMembers, data?.config.leadSessionId, data?.isAlive, - data?.tasks, handleCreateTaskFromMessage, - handleOpenTask, + handleOpenMessagePanelTask, handleReplyToMessage, handleRestartTeam, handleSelectMember, handleTaskIdClick, + messagesPanelTasks, messagesPanelMountPoint, pendingRepliesByMember, teamName, - teamSessionIds, timeWindow, changeMessagesPanelMode, ] diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 145dd9b0..8a3e3841 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -1,4 +1,5 @@ import { + type ComponentProps, memo, type RefObject, useCallback, @@ -216,6 +217,109 @@ export function hasVisibleReplyForSendMessageDiagnostics( }); } +const MessagesComposerSection = memo(MessageComposer); +const MessagesStatusSection = memo(StatusBlock); + +type MessagesTimelineSectionProps = ComponentProps & { + hasMore: boolean; + loadingOlderMessages: boolean; + onLoadOlderMessages: () => void; + expandedItem: TimelineItem | null; + expandedItemKey: string | null; + onExpandDialogChange: (open: boolean) => void; +}; + +const MessagesTimelineSection = memo(function MessagesTimelineSection({ + hasMore, + loadingOlderMessages, + onLoadOlderMessages, + expandedItem, + expandedItemKey, + onExpandDialogChange, + messages, + teamName, + members, + readState, + allCollapsed, + expandOverrides, + onToggleExpandOverride, + currentLeadSessionId, + isTeamAlive, + leadActivity, + leadContextUpdatedAt, + teamNames, + teamColorByName, + onTeamClick, + onMemberClick, + onCreateTaskFromMessage, + onReplyToMessage, + onMessageVisible, + onRestartTeam, + onTaskIdClick, + onExpandItem, + onExpandContent, + viewport, +}: MessagesTimelineSectionProps): React.JSX.Element { + return ( + <> + + {hasMore && ( +
+ +
+ )} + + + ); +}); + export const MessagesPanel = memo(function MessagesPanel({ teamName, position, @@ -282,8 +386,10 @@ export const MessagesPanel = memo(function MessagesPanel({ await loadOlderTeamMessages(teamName); }, [loadOlderTeamMessages, messagesState, teamName]); - const messagesLoading = - (messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); + const handleLoadOlderMessagesClick = useCallback(() => { + void loadOlderMessages(); + }, [loadOlderMessages]); + const loadingOlderMessages = messagesState?.loadingOlder ?? false; const hasMore = messagesState?.hasMore ?? false; const effectiveMessages = messages; @@ -723,6 +829,99 @@ export const MessagesPanel = memo(function MessagesPanel({ ); }, [bottomSheetSnapIndex]); + const defaultComposerSection = ( + + ); + + const compactComposerSection = ( + + ); + + const inlineStatusSection = ( + + ); + + const sidebarStatusSection = ( + + ); + + const timelineSection = ( + + ); + // ---- Shared content (used in both modes) ---- const searchAndFilterControls = (
@@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({ const messagesContent = (
- - - - {hasMore && ( -
- -
- )} - + {defaultComposerSection} + {inlineStatusSection} + {timelineSection}
); @@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({ onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} >
- - {' '} + {defaultComposerSection} + {sidebarStatusSection}
- - {hasMore && ( -
- -
- )} - + {timelineSection}
); @@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({ {searchAndFilterControls} )} -
- -
+
{compactComposerSection}
-
- -
-
- - {hasMore && ( -
- -
- )} -
- +
{inlineStatusSection}
+
{timelineSection}
)} diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx index 2974ec29..dc272180 100644 --- a/src/renderer/components/team/messages/StatusBlock.tsx +++ b/src/renderer/components/team/messages/StatusBlock.tsx @@ -66,12 +66,12 @@ export const StatusBlock = ({ return hasActiveTasks; }, [hasActiveTasks, hasPendingReplies]); - // Only run the 1-second timer when the block actually has content to show. + // Only pending reply TTL labels need a 1-second refresh. useEffect(() => { - if (!hasItems) return; + if (!hasPendingReplies) return; const id = window.setInterval(() => setNowMs(Date.now()), 1000); return () => window.clearInterval(id); - }, [hasItems]); + }, [hasPendingReplies]); if (!hasItems) return null; diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts index 60527737..430c2d9a 100644 --- a/src/renderer/components/team/teamRuntimeDisplayRows.ts +++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts @@ -135,8 +135,11 @@ function buildRuntimeBackedDisplayRow( const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error'; const spawnDegradation = getSpawnDegradation(spawn); const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null); + const degradedReason = spawnDegradation + ? withLiveProcessContext(spawnDegradation.reason, runtime) + : undefined; const stateReason = - spawnDegradation?.reason ?? + degradedReason ?? runtime.runtimeDiagnostic ?? (runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive'); @@ -151,7 +154,10 @@ function buildRuntimeBackedDisplayRow( laneId: runtime.laneId, laneKind: runtime.laneKind, runtimeModel: runtime.runtimeModel, - diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic, + diagnostic: + spawnDegradation && degradedReason + ? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime) + : runtime.runtimeDiagnostic, diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity, pidLabel: formatRuntimePidLabel(runtime), actionsAllowed: false, @@ -213,6 +219,13 @@ function getRuntimeBackedState( return runtime.alive === true ? 'running' : 'stopped'; } +function withLiveProcessContext(reason: string, runtime: TeamAgentRuntimeEntry): string { + if (runtime.alive !== true || /process is still alive/i.test(reason)) { + return reason; + } + return `${reason}. Process is still alive.`; +} + function buildSpawnBackedDisplayRow( memberName: string, spawn: MemberSpawnStatusEntry diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index 5cce4646..3916c01c 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -30,6 +30,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde if (normalized === 'empty_assistant_turn') { return 'OpenCode returned an empty assistant turn.'; } + if (normalized === 'prompt_delivered_no_assistant_message') { + return 'OpenCode accepted the prompt, but no assistant turn was recorded.'; + } return ''; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 7507ca1a..37a8e8f5 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -712,6 +712,7 @@ export interface SendMessageResult { | 'permission_blocked' | 'tool_error' | 'empty_assistant_turn' + | 'prompt_delivered_no_assistant_message' | 'session_stale' | 'session_error' | 'reconcile_failed'; diff --git a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts index cdde0127..60d4b1a8 100644 --- a/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts +++ b/test/main/services/team/OpenCodeProductionPromptArtifacts.safe-e2e.test.ts @@ -125,6 +125,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => { expect(directCommand?.text).toContain('Include source="runtime_delivery"'); 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('"displayId":"59560c95"'); expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message'); expect(directCommand?.text).toContain('never use #00000000'); diff --git a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts index c1f5f2aa..8f21a502 100644 --- a/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts +++ b/test/main/services/team/OpenCodePromptDeliveryLedger.test.ts @@ -255,6 +255,38 @@ describe('OpenCodePromptDeliveryLedger', () => { expect(emptyResult.responseState).toBe('empty_assistant_turn'); expect(emptyResult.attempts).toBe(1); + const noAssistant = await store.ensurePending({ + teamName: 'team-a', + memberName: 'jack', + laneId: 'secondary:opencode:jack', + inboxMessageId: 'msg-no-assistant', + inboxTimestamp: '2026-04-25T09:59:05.000Z', + source: 'watcher', + replyRecipient: 'user', + payloadHash: 'sha256:no-assistant', + now: '2026-04-25T10:00:06.000Z', + }); + const noAssistantResult = await store.applyDeliveryResult({ + id: noAssistant.id, + accepted: true, + attempted: true, + responseObservation: { + state: 'prompt_delivered_no_assistant_message', + deliveredUserMessageId: 'oc-user-no-assistant', + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'prompt_delivered_no_assistant_message', + }, + now: '2026-04-25T10:00:07.000Z', + }); + + expect(noAssistantResult.status).toBe('unanswered'); + expect(noAssistantResult.responseState).toBe('prompt_delivered_no_assistant_message'); + const plain = await store.ensurePending({ teamName: 'team-a', memberName: 'jack', diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index ef3c6dac..65c80af3 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Action mode for this message: delegate.'); + expect(sentText).toContain('You must not end this turn empty.'); expect(sentText).toContain(''); expect(sentText).toContain('"kind":"opencode-delivery-context"'); expect(sentText).toContain('"inboundMessageId":"msg-1"'); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 69dc98d1..742ff85a 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -6955,22 +6955,123 @@ describe('TeamProvisioningService', () => { delivered: true, diagnostics: [], }); - expect(sendMessageToMember).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'opencode-run-durable', - teamName, - laneId, + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-durable', + teamName, + laneId, memberName: 'bob', cwd: '/repo', text: 'hello after restart', messageId: 'msg-after-restart', - }) - ); - }); + }) + ); + }); - it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { - const svc = new TeamProvisioningService(); - const teamName = 'team-a'; + it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; + const laneId = 'secondary:opencode:bob'; + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]) + ); + + (svc as any).aliveRunByTeam.set(teamName, 'primary-run'); + (svc as any).runs.set('primary-run', { + runId: 'primary-run', + teamName, + processKilled: false, + cancelRequested: false, + progress: { state: 'ready' }, + request: { providerId: 'codex', cwd: '/repo' }, + mixedSecondaryLanes: [ + { + laneId, + providerId: 'opencode', + member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + runId: 'opencode-run-live', + state: 'finished', + result: { + members: { + bob: { + bootstrapConfirmed: true, + launchState: 'confirmed_alive', + sessionId: 'oc-session-bob', + }, + }, + }, + warnings: [], + diagnostics: [], + }, + ], + }); + (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(teamName, { + memberName: 'bob', + text: 'hello live lane', + messageId: 'msg-live-lane', + }) + ).resolves.toMatchObject({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'opencode-run-live', + teamName, + laneId, + memberName: 'bob', + }) + ); + expect(sendMessageToMember).not.toHaveBeenCalledWith( + expect.objectContaining({ runId: 'primary-run' }) + ); + }); + + it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'team-a'; const laneId = 'secondary:opencode:bob'; const sendMessageToMember = vi.fn(async (input: Record) => ({ ok: true, @@ -12351,6 +12452,149 @@ describe('TeamProvisioningService', () => { }); }); + it('heals terminal bootstrap-state failures when runtime proof confirms member_briefing', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-runtime-proof-heals'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const proofAt = new Date(Date.now() - 60_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack'; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + source: 'member_briefing_tool_success', + bootstrapProofToken: proofToken, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: true, + hardFailure: false, + error: undefined, + }); + }); + + it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => { + allowConsoleLogs(); + const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const proofAt = new Date(Date.now() - 120_000).toISOString(); + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const failureAt = new Date(Date.now() - 30_000).toISOString(); + const proofToken = 'proof-token-jack'; + const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl'); + + writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']); + const configPath = path.join(tempTeamsBase, teamName, 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { + members: Array>; + }; + config.members = config.members.map((member) => + member.name === 'jack' + ? { + ...member, + agentId: `jack@${teamName}`, + bootstrapExpectedAfter: acceptedAt, + bootstrapProofToken: proofToken, + bootstrapRuntimeEventsPath: runtimeEventsPath, + } + : member + ); + fs.writeFileSync(configPath, JSON.stringify(config), 'utf8'); + writeBootstrapState( + teamName, + [ + { + name: 'jack', + status: 'failed', + lastAttemptAt: Date.parse(acceptedAt), + lastObservedAt: Date.parse(failureAt), + failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.', + }, + ], + failureAt + ); + fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true }); + fs.writeFileSync( + runtimeEventsPath, + `${JSON.stringify({ + version: 1, + type: 'bootstrap_confirmed', + timestamp: proofAt, + pid: 1234, + teamName, + agentName: 'jack', + agentId: `jack@${teamName}`, + source: 'member_briefing_tool_success', + bootstrapProofToken: proofToken, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + bootstrapConfirmed: false, + runtimeAlive: false, + hardFailure: true, + }); + }); + it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => { allowConsoleLogs(); const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored'; diff --git a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts index 186c52f9..3298f44e 100644 --- a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts +++ b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts @@ -158,7 +158,7 @@ describe('buildTeamRuntimeDisplayRows', () => { memberName: 'alice', state: 'degraded', source: 'mixed', - stateReason: 'Bootstrap command failed', + stateReason: 'Bootstrap command failed. Process is still alive.', actionsAllowed: false, }); }); diff --git a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts index 84e91000..f4f3be51 100644 --- a/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts +++ b/test/renderer/utils/openCodeRuntimeDeliveryDiagnostics.test.ts @@ -27,4 +27,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => { reason: 'empty_assistant_turn', }); }); + + it('surfaces prompt delivery with no recorded assistant turn separately', () => { + const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({ + deliveredToInbox: true, + messageId: 'msg-no-assistant', + runtimeDelivery: { + providerId: 'opencode', + attempted: true, + delivered: false, + responsePending: false, + responseState: 'prompt_delivered_no_assistant_message', + ledgerStatus: 'failed_terminal', + reason: 'prompt_delivered_no_assistant_message', + diagnostics: ['prompt_delivered_no_assistant_message'], + }, + }); + + expect(diagnostics.warning).toBe( + 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode accepted the prompt, but no assistant turn was recorded.' + ); + expect(diagnostics.debugDetails).toMatchObject({ + responseState: 'prompt_delivered_no_assistant_message', + reason: 'prompt_delivered_no_assistant_message', + }); + }); });