fix: stabilize OpenCode team runtime delivery

This commit is contained in:
777genius 2026-05-05 17:07:21 +03:00
parent ab50c43383
commit bbafedf06a
17 changed files with 935 additions and 295 deletions

View file

@ -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<string, unknown> {
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<string, unknown>) : {};
} catch {
return {};
}
}
function getRuntimeBootstrapProofString(
event: Record<string, unknown>,
detail: Record<string, unknown>,
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<OpenCodeTeamRuntimeMessageResult['responseObservation']>
): NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']> | 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<Record<string, unknown>[]> {
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<string, unknown>[] = [];
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<string, unknown>);
}
} 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<string, unknown>;
detail: Record<string, unknown>;
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<string | null> {
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<PersistedTeamLaunchSnapshot | null> {
@ -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) {

View file

@ -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';

View file

@ -87,6 +87,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>
'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<OpenCodePromptDeliveryLedgerRecord> {
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()

View file

@ -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' ||

View file

@ -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.',

View file

@ -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<TeamProviderId>(['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<SharedTeamMessagesPanelProps>(
() => ({
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,
]

View file

@ -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<typeof ActivityTimeline> & {
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 (
<>
<ActivityTimeline
messages={messages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={allCollapsed}
expandOverrides={expandOverrides}
onToggleExpandOverride={onToggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={onMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={onExpandItem}
onExpandContent={onExpandContent}
viewport={viewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={onLoadOlderMessages}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={onExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</>
);
});
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 = (
<MessagesComposerSection
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
);
const compactComposerSection = (
<MessagesComposerSection
teamName={teamName}
layout="compact"
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
);
const inlineStatusSection = (
<MessagesStatusSection
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
);
const sidebarStatusSection = (
<MessagesStatusSection
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="sidebar"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
);
const timelineSection = (
<MessagesTimelineSection
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
hasMore={hasMore}
loadingOlderMessages={loadingOlderMessages}
onLoadOlderMessages={handleLoadOlderMessagesClick}
expandedItem={expandedItem}
expandedItemKey={expandedItemKey}
onExpandDialogChange={handleExpandDialogChange}
/>
);
// ---- Shared content (used in both modes) ----
const searchAndFilterControls = (
<div className="flex items-center gap-2">
@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({
const messagesContent = (
<div className="pb-14">
<MessageComposer
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
<StatusBlock
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
<ActivityTimeline
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
{defaultComposerSection}
{inlineStatusSection}
{timelineSection}
</div>
);
@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
>
<div className="pl-3">
<MessageComposer
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
<StatusBlock
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="sidebar"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>{' '}
{defaultComposerSection}
{sidebarStatusSection}
</div>
<ActivityTimeline
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
{timelineSection}
</div>
</div>
);
@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({
{searchAndFilterControls}
</div>
)}
<div className="p-3">
<MessageComposer
teamName={teamName}
layout="compact"
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
</div>
<div className="p-3">{compactComposerSection}</div>
</div>
<div className="shrink-0 px-3 pt-2">
<StatusBlock
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
</div>
<div className="flex-1 px-3 pb-4 pt-2">
<ActivityTimeline
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
</div>
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
<div className="shrink-0 px-3 pt-2">{inlineStatusSection}</div>
<div className="flex-1 px-3 pb-4 pt-2">{timelineSection}</div>
</Sheet.Content>
)}
</Sheet.Container>

View file

@ -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;

View file

@ -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

View file

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

View file

@ -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';

View file

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

View file

@ -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',

View file

@ -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('<opencode_delivery_context>');
expect(sentText).toContain('"kind":"opencode-delivery-context"');
expect(sentText).toContain('"inboundMessageId":"msg-1"');

View file

@ -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<string, unknown>) => ({
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<string, unknown>) => ({
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<Record<string, unknown>>;
};
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<Record<string, unknown>>;
};
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';

View file

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

View file

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