fix(team): harden opencode launch diagnostics
This commit is contained in:
parent
3d8d2395a4
commit
c8edfc6026
10 changed files with 1426 additions and 47 deletions
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.16",
|
||||
"sourceRef": "v0.0.16",
|
||||
"version": "0.0.17",
|
||||
"sourceRef": "v0.0.17",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.16.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.16.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.16.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.17.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.16.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.17.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
bootstrapStalled?: boolean;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
diagnostics?: string[];
|
||||
} | null;
|
||||
pendingReason?: string;
|
||||
|
|
@ -108,6 +109,59 @@ function hasMaterializedOpenCodeRuntimeMarker(value: {
|
|||
);
|
||||
}
|
||||
|
||||
const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN =
|
||||
/\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i;
|
||||
|
||||
function normalizeIsoTimestamp(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
||||
}
|
||||
|
||||
function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined {
|
||||
let selected: { value: string; timeMs: number } | null = null;
|
||||
for (const value of values) {
|
||||
const normalized = normalizeIsoTimestamp(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const timeMs = Date.parse(normalized);
|
||||
if (!selected || timeMs < selected.timeMs) {
|
||||
selected = { value: normalized, timeMs };
|
||||
}
|
||||
}
|
||||
return selected?.value;
|
||||
}
|
||||
|
||||
function extractOpenCodeMemberSessionRecordedAt(
|
||||
diagnostics: readonly string[] | undefined
|
||||
): string[] {
|
||||
return (diagnostics ?? []).flatMap((diagnostic) => {
|
||||
const match = diagnostic.match(OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN);
|
||||
return match?.[1] ? [match[1]] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOpenCodeSecondaryFirstSpawnAcceptedAt(
|
||||
evidence: NonNullable<MixedSecondaryLaneMemberStateInput['evidence']>,
|
||||
fallbackUpdatedAt: string
|
||||
): string | undefined {
|
||||
if (evidence.agentToolAccepted !== true) {
|
||||
return undefined;
|
||||
}
|
||||
return selectEarliestIsoTimestamp([
|
||||
evidence.firstSpawnAcceptedAt,
|
||||
...extractOpenCodeMemberSessionRecordedAt(evidence.diagnostics),
|
||||
fallbackUpdatedAt,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
|
|
@ -252,6 +306,9 @@ function createSecondaryLaneMemberState(
|
|||
});
|
||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
||||
const firstSpawnAcceptedAt = evidence
|
||||
? resolveOpenCodeSecondaryFirstSpawnAcceptedAt(evidence, params.updatedAt)
|
||||
: undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
providerId,
|
||||
|
|
@ -304,7 +361,7 @@ function createSecondaryLaneMemberState(
|
|||
hardFailure !== true
|
||||
? true
|
||||
: undefined,
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||
firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -141,7 +141,7 @@ export interface ApplyOpenCodePromptDestinationProofInput {
|
|||
id: string;
|
||||
visibleReplyInbox: string;
|
||||
visibleReplyMessageId: string;
|
||||
visibleReplyCorrelation: 'relayOfMessageId';
|
||||
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation;
|
||||
semanticallySufficient: boolean;
|
||||
diagnostics?: string[];
|
||||
observedAt: string;
|
||||
|
|
@ -357,10 +357,14 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
async applyDestinationProof(
|
||||
input: ApplyOpenCodePromptDestinationProofInput
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
const responseState =
|
||||
input.visibleReplyCorrelation === 'plain_assistant_text'
|
||||
? 'responded_plain_text'
|
||||
: 'responded_visible_message';
|
||||
return await this.updateExisting(input.id, (record) => ({
|
||||
...record,
|
||||
status: input.semanticallySufficient ? 'responded' : record.status,
|
||||
responseState: 'responded_visible_message',
|
||||
responseState,
|
||||
lastObservedAt: input.observedAt,
|
||||
respondedAt: input.semanticallySufficient
|
||||
? (record.respondedAt ?? input.observedAt)
|
||||
|
|
@ -786,6 +790,14 @@ function isTaskRefArray(value: unknown): value is TaskRef[] {
|
|||
}
|
||||
|
||||
function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
||||
if (
|
||||
record.status === 'responded' &&
|
||||
record.responseState === 'responded_plain_text' &&
|
||||
!record.visibleReplyMessageId &&
|
||||
!record.inboxReadCommittedAt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return record.status === 'failed_terminal' || record.status === 'responded';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
|||
replyRecipient?: string;
|
||||
actionMode?: AgentActionMode;
|
||||
taskRefs?: TaskRef[];
|
||||
bootstrapCheckinRetry?: {
|
||||
runtimeSessionId: string;
|
||||
reason?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageResult {
|
||||
|
|
@ -743,6 +747,30 @@ function buildMemberBootstrapPrompt(
|
|||
}
|
||||
|
||||
function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string {
|
||||
if (input.bootstrapCheckinRetry) {
|
||||
const runtimeSessionId = input.bootstrapCheckinRetry.runtimeSessionId.trim();
|
||||
return [
|
||||
'<opencode_runtime_bootstrap_checkin_retry>',
|
||||
'The desktop app detected that this OpenCode session exists, but runtime_bootstrap_checkin has not committed durable runtime evidence yet.',
|
||||
input.bootstrapCheckinRetry.reason
|
||||
? `Reason: ${input.bootstrapCheckinRetry.reason.trim()}`
|
||||
: null,
|
||||
'Before any other tool or message, call MCP tool agent-teams_runtime_bootstrap_checkin or mcp__agent-teams__runtime_bootstrap_checkin with exactly:',
|
||||
JSON.stringify({
|
||||
runId: input.runId,
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
runtimeSessionId,
|
||||
}),
|
||||
'Do not call member_briefing, task tools, message_send, or cross_team_send before runtime_bootstrap_checkin completes.',
|
||||
'After runtime_bootstrap_checkin succeeds, stop this turn immediately and wait silently.',
|
||||
'If runtime_bootstrap_checkin is unavailable or fails, reply with one short sentence containing the exact error text, then stop.',
|
||||
'</opencode_runtime_bootstrap_checkin_retry>',
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const replyRecipient = input.replyRecipient?.trim() || 'user';
|
||||
const deliveryContext =
|
||||
input.messageId && input.taskRefs?.length
|
||||
|
|
|
|||
|
|
@ -22,6 +22,17 @@ const PENDING_WARNING =
|
|||
const FAILED_WARNING =
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||
|
||||
function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | undefined): string {
|
||||
const normalized = reason?.trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized === 'empty_assistant_turn') {
|
||||
return 'OpenCode returned an empty assistant turn.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||
result: SendMessageResult
|
||||
): OpenCodeRuntimeDeliveryDiagnostics {
|
||||
|
|
@ -36,8 +47,19 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
|||
return { warning: null, debugDetails: null };
|
||||
}
|
||||
|
||||
const failureReason = isFailed
|
||||
? formatOpenCodeRuntimeDeliveryFailureReason(
|
||||
runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
|
||||
)
|
||||
: '';
|
||||
|
||||
return {
|
||||
warning: isFailed ? FAILED_WARNING : PENDING_WARNING,
|
||||
warning:
|
||||
isFailed && failureReason
|
||||
? `${FAILED_WARNING} Reason: ${failureReason}`
|
||||
: isFailed
|
||||
? FAILED_WARNING
|
||||
: PENDING_WARNING,
|
||||
debugDetails: {
|
||||
messageId: result.messageId,
|
||||
providerId: runtimeDelivery.providerId,
|
||||
|
|
|
|||
|
|
@ -286,6 +286,71 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
expect(observed.observedAssistantPreview).toBe('Понял');
|
||||
});
|
||||
|
||||
it('keeps plain-text responses active until their visible inbox reply is materialized', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-plain-visible',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
actionMode: 'ask',
|
||||
taskRefs: [],
|
||||
payloadHash: 'sha256:plain-visible',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const responded = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: true,
|
||||
attempted: true,
|
||||
responseObservation: {
|
||||
state: 'responded_plain_text',
|
||||
deliveredUserMessageId: 'oc-user-plain',
|
||||
assistantMessageId: 'oc-assistant-plain',
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: 'Concrete visible answer.',
|
||||
reason: null,
|
||||
},
|
||||
now: '2026-04-25T10:00:05.000Z',
|
||||
});
|
||||
expect(responded.status).toBe('responded');
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})).resolves.toMatchObject({
|
||||
id: record.id,
|
||||
responseState: 'responded_plain_text',
|
||||
});
|
||||
|
||||
const materialized = await store.applyDestinationProof({
|
||||
id: record.id,
|
||||
visibleReplyInbox: 'user',
|
||||
visibleReplyMessageId: 'opencode-plain-reply-1',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
semanticallySufficient: true,
|
||||
observedAt: '2026-04-25T10:00:06.000Z',
|
||||
});
|
||||
expect(materialized).toMatchObject({
|
||||
status: 'responded',
|
||||
responseState: 'responded_plain_text',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
});
|
||||
|
||||
await expect(store.getActiveForMember({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not keep responded live deliveries active when no inbox commit is needed', async () => {
|
||||
const store = createStore();
|
||||
const direct = await store.ensurePending({
|
||||
|
|
|
|||
|
|
@ -895,6 +895,133 @@ describe('TeamProvisioningService', () => {
|
|||
expect(payload.body).not.toContain('0/4');
|
||||
expect(payload.body).not.toContain('did not join');
|
||||
});
|
||||
|
||||
it('does not report persisted bootstrap-confirmed primary members as failed from a stale failed list', async () => {
|
||||
const { NotificationManager } =
|
||||
await import('@main/services/infrastructure/NotificationManager');
|
||||
const addTeamNotification = vi.fn(async (_payload: unknown) => undefined);
|
||||
NotificationManager.setInstance({ addTeamNotification } as never);
|
||||
|
||||
try {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = {
|
||||
runId: 'run-forge-labs-15',
|
||||
teamName: 'forge-labs-15',
|
||||
isLaunch: true,
|
||||
request: {
|
||||
cwd: tempClaudeRoot,
|
||||
displayName: 'forge-labs-15',
|
||||
},
|
||||
expectedMembers: ['bob', 'jack', 'alice', 'tom'],
|
||||
allEffectiveMembers: [
|
||||
{ name: 'bob' },
|
||||
{ name: 'jack' },
|
||||
{ name: 'alice' },
|
||||
{ name: 'tom' },
|
||||
],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'jack',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'tom',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
};
|
||||
const reconciledSnapshot = {
|
||||
expectedMembers: ['bob', 'jack', 'alice', 'tom'],
|
||||
members: {
|
||||
bob: {
|
||||
name: 'bob',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-05-04T19:32:37.000Z',
|
||||
},
|
||||
jack: {
|
||||
name: 'jack',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-05-04T19:32:30.000Z',
|
||||
},
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-05-04T19:35:49.000Z',
|
||||
},
|
||||
tom: {
|
||||
name: 'tom',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-05-04T19:35:49.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 3,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
await (svc as any).fireTeamLaunchIncompleteNotification(
|
||||
run,
|
||||
[{ name: 'bob' }, { name: 'jack' }],
|
||||
reconciledSnapshot.summary,
|
||||
reconciledSnapshot
|
||||
);
|
||||
} finally {
|
||||
NotificationManager.resetInstance();
|
||||
}
|
||||
|
||||
expect(addTeamNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getClaudeLogs', () => {
|
||||
|
|
@ -4882,7 +5009,7 @@ describe('TeamProvisioningService', () => {
|
|||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
latestAssistantPreview: 'Answer after observe.',
|
||||
reason: null,
|
||||
},
|
||||
diagnostics: [],
|
||||
|
|
@ -4971,8 +5098,21 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_plain_text',
|
||||
responsePending: false,
|
||||
responseState: 'responded_plain_text',
|
||||
visibleReplyCorrelation: 'plain_assistant_text',
|
||||
});
|
||||
|
||||
const userInbox = JSON.parse(
|
||||
await fsPromises.readFile(path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), 'utf8')
|
||||
) as Array<Record<string, unknown>>;
|
||||
expect(userInbox).toHaveLength(1);
|
||||
expect(userInbox[0]).toMatchObject({
|
||||
from: 'bob',
|
||||
to: 'user',
|
||||
text: 'Answer after observe.',
|
||||
relayOfMessageId: 'msg-ledger-1',
|
||||
source: 'runtime_delivery',
|
||||
});
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -14319,6 +14459,205 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('self-heals stale persisted OpenCode secondary bootstrap without live metadata', async () => {
|
||||
const teamName = 'zz-opencode-persisted-bootstrap-stall-no-live';
|
||||
const leadSessionId = 'lead-session';
|
||||
const acceptedAt = new Date(Date.now() - 6 * 60_000).toISOString();
|
||||
|
||||
writeTeamMeta(teamName, {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
});
|
||||
writeMembersMeta(teamName, [
|
||||
{
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/minimax/minimax-m2.5',
|
||||
},
|
||||
]);
|
||||
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['tom']);
|
||||
writeLaunchState(teamName, leadSessionId, {
|
||||
tom: {
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/minimax/minimax-m2.5',
|
||||
laneId: 'secondary:opencode:tom',
|
||||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
runtimeSessionId: 'ses_tom_partial',
|
||||
runtimePid: 55947,
|
||||
livenessKind: 'registered_only',
|
||||
firstSpawnAcceptedAt: acceptedAt,
|
||||
diagnostics: [
|
||||
'OpenCode bootstrap MCP tool failed before required attach completed: runtime_bootstrap_checkin',
|
||||
'member_briefing at 2026-05-04T18:25:43.091Z',
|
||||
],
|
||||
lastEvaluatedAt: acceptedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'getLiveTeamAgentRuntimeMetadata').mockResolvedValue(new Map());
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
expect(result.statuses.tom).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
bootstrapStalled: true,
|
||||
runtimeDiagnostic:
|
||||
'OpenCode bootstrap MCP tool failed before required attach completed: runtime_bootstrap_checkin',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
const persisted = JSON.parse(
|
||||
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
|
||||
);
|
||||
expect(persisted.members.tom).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
bootstrapStalled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sends one targeted OpenCode bootstrap check-in retry when a partial bootstrap stalls', async () => {
|
||||
const teamName = 'zz-opencode-bootstrap-checkin-retry';
|
||||
const acceptedAt = new Date(Date.now() - 6 * 60_000).toISOString();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
diagnostics: [],
|
||||
}));
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
])
|
||||
);
|
||||
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);
|
||||
vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined);
|
||||
vi.spyOn(svc as any, 'getLiveTeamAgentRuntimeMetadata').mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'tom',
|
||||
{
|
||||
alive: true,
|
||||
providerId: 'opencode',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeSessionId: 'ses_tom_partial',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
runId: 'run-bootstrap-checkin-retry',
|
||||
expectedMembers: ['tom'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'tom',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
firstSpawnAcceptedAt: acceptedAt,
|
||||
livenessKind: 'runtime_process',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.onProgress = vi.fn();
|
||||
run.isLaunch = false;
|
||||
run.request = {
|
||||
teamName,
|
||||
cwd: '/Users/test/proj',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
members: [],
|
||||
};
|
||||
run.mixedSecondaryLanes = [
|
||||
{
|
||||
laneId: 'secondary:opencode:tom',
|
||||
providerId: 'opencode',
|
||||
member: {
|
||||
name: 'tom',
|
||||
providerId: 'opencode',
|
||||
model: 'openrouter/minimax/minimax-m2.5',
|
||||
cwd: '/Users/test/proj',
|
||||
},
|
||||
runId: 'opencode-run-tom',
|
||||
state: 'finished',
|
||||
result: {
|
||||
runId: 'opencode-run-tom',
|
||||
teamName,
|
||||
launchPhase: 'active',
|
||||
teamLaunchState: 'partial_pending',
|
||||
members: {
|
||||
tom: {
|
||||
memberName: 'tom',
|
||||
providerId: 'opencode',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
sessionId: 'ses_tom_partial',
|
||||
diagnostics: [
|
||||
'runtime_bootstrap_checkin failed: Not connected',
|
||||
'member_briefing at 2026-05-04T18:25:43.091Z',
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
},
|
||||
];
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
|
||||
await (svc as any).reevaluateMemberLaunchStatus(run, 'tom');
|
||||
await (svc as any).reevaluateMemberLaunchStatus(run, 'tom');
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: 'opencode-run-tom',
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:tom',
|
||||
memberName: 'tom',
|
||||
cwd: '/Users/test/proj',
|
||||
bootstrapCheckinRetry: {
|
||||
runtimeSessionId: 'ses_tom_partial',
|
||||
reason: 'runtime_bootstrap_checkin failed: Not connected',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
|
|
@ -16288,6 +16627,8 @@ describe('TeamProvisioningService', () => {
|
|||
const teamName = 'atlas-hq-source-aware-persisted';
|
||||
const exactOpenCodeReason =
|
||||
'Latest assistant message msg_alice failed with APIError - Insufficient credits.';
|
||||
const transientBobMcpFailure =
|
||||
'resources/read failed: resources/read failed for `agent-teams` (member_briefing?teamName=atlas-hq-source-aware-persisted&memberName=bob): Mcp error: -32601: Method not found';
|
||||
writeTeamMeta(teamName, {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
|
|
@ -16337,7 +16678,7 @@ describe('TeamProvisioningService', () => {
|
|||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
hardFailureReason: transientBobMcpFailure,
|
||||
lastEvaluatedAt: '2026-04-23T10:02:00.000Z',
|
||||
},
|
||||
jack: {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,29 @@ describe('OpenCodeDeliveryWarning', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows terminal empty assistant turn reason in the compact failed warning', async () => {
|
||||
const failedWarning =
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.';
|
||||
const { host, root } = renderWarning({
|
||||
warning: failedWarning,
|
||||
debugDetails: {
|
||||
...debugDetails,
|
||||
delivered: false,
|
||||
responsePending: false,
|
||||
responseState: 'empty_assistant_turn',
|
||||
ledgerStatus: 'failed_terminal',
|
||||
reason: 'empty_assistant_turn',
|
||||
diagnostics: ['empty_assistant_turn'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain(failedWarning);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides details again when a different runtime delivery payload arrives', async () => {
|
||||
const { host, root } = renderWarning();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildOpenCodeRuntimeDeliveryDiagnostics } from '../../../src/renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||
|
||||
describe('openCodeRuntimeDeliveryDiagnostics', () => {
|
||||
it('surfaces terminal empty assistant turn in the compact failed warning', () => {
|
||||
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'msg-empty',
|
||||
runtimeDelivery: {
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: false,
|
||||
responsePending: false,
|
||||
responseState: 'empty_assistant_turn',
|
||||
ledgerStatus: 'failed_terminal',
|
||||
reason: 'empty_assistant_turn',
|
||||
diagnostics: ['empty_assistant_turn'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(diagnostics.warning).toBe(
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
|
||||
);
|
||||
expect(diagnostics.debugDetails).toMatchObject({
|
||||
responseState: 'empty_assistant_turn',
|
||||
reason: 'empty_assistant_turn',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue