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",
|
"version": "0.0.17",
|
||||||
"sourceRef": "v0.0.16",
|
"sourceRef": "v0.0.17",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.16.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.17.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export interface MixedSecondaryLaneMemberStateInput {
|
||||||
runtimeDiagnostic?: string;
|
runtimeDiagnostic?: string;
|
||||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
bootstrapStalled?: boolean;
|
bootstrapStalled?: boolean;
|
||||||
|
firstSpawnAcceptedAt?: string;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
pendingReason?: string;
|
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(
|
function buildDiagnostics(
|
||||||
member: Pick<
|
member: Pick<
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
|
|
@ -252,6 +306,9 @@ function createSecondaryLaneMemberState(
|
||||||
});
|
});
|
||||||
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start';
|
||||||
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined;
|
||||||
|
const firstSpawnAcceptedAt = evidence
|
||||||
|
? resolveOpenCodeSecondaryFirstSpawnAcceptedAt(evidence, params.updatedAt)
|
||||||
|
: undefined;
|
||||||
const base: PersistedTeamLaunchMemberState = {
|
const base: PersistedTeamLaunchMemberState = {
|
||||||
name: params.member.name.trim(),
|
name: params.member.name.trim(),
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -304,7 +361,7 @@ function createSecondaryLaneMemberState(
|
||||||
hardFailure !== true
|
hardFailure !== true
|
||||||
? true
|
? true
|
||||||
: undefined,
|
: undefined,
|
||||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||||
lastRuntimeAliveAt: 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;
|
id: string;
|
||||||
visibleReplyInbox: string;
|
visibleReplyInbox: string;
|
||||||
visibleReplyMessageId: string;
|
visibleReplyMessageId: string;
|
||||||
visibleReplyCorrelation: 'relayOfMessageId';
|
visibleReplyCorrelation: OpenCodeDeliveryVisibleReplyCorrelation;
|
||||||
semanticallySufficient: boolean;
|
semanticallySufficient: boolean;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
observedAt: string;
|
observedAt: string;
|
||||||
|
|
@ -357,10 +357,14 @@ export class OpenCodePromptDeliveryLedgerStore {
|
||||||
async applyDestinationProof(
|
async applyDestinationProof(
|
||||||
input: ApplyOpenCodePromptDestinationProofInput
|
input: ApplyOpenCodePromptDestinationProofInput
|
||||||
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||||
|
const responseState =
|
||||||
|
input.visibleReplyCorrelation === 'plain_assistant_text'
|
||||||
|
? 'responded_plain_text'
|
||||||
|
: 'responded_visible_message';
|
||||||
return await this.updateExisting(input.id, (record) => ({
|
return await this.updateExisting(input.id, (record) => ({
|
||||||
...record,
|
...record,
|
||||||
status: input.semanticallySufficient ? 'responded' : record.status,
|
status: input.semanticallySufficient ? 'responded' : record.status,
|
||||||
responseState: 'responded_visible_message',
|
responseState,
|
||||||
lastObservedAt: input.observedAt,
|
lastObservedAt: input.observedAt,
|
||||||
respondedAt: input.semanticallySufficient
|
respondedAt: input.semanticallySufficient
|
||||||
? (record.respondedAt ?? input.observedAt)
|
? (record.respondedAt ?? input.observedAt)
|
||||||
|
|
@ -786,6 +790,14 @@ function isTaskRefArray(value: unknown): value is TaskRef[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalForAutomaticSelection(record: OpenCodePromptDeliveryLedgerRecord): boolean {
|
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';
|
return record.status === 'failed_terminal' || record.status === 'responded';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,10 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
||||||
replyRecipient?: string;
|
replyRecipient?: string;
|
||||||
actionMode?: AgentActionMode;
|
actionMode?: AgentActionMode;
|
||||||
taskRefs?: TaskRef[];
|
taskRefs?: TaskRef[];
|
||||||
|
bootstrapCheckinRetry?: {
|
||||||
|
runtimeSessionId: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeTeamRuntimeMessageResult {
|
export interface OpenCodeTeamRuntimeMessageResult {
|
||||||
|
|
@ -743,6 +747,30 @@ function buildMemberBootstrapPrompt(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string {
|
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 replyRecipient = input.replyRecipient?.trim() || 'user';
|
||||||
const deliveryContext =
|
const deliveryContext =
|
||||||
input.messageId && input.taskRefs?.length
|
input.messageId && input.taskRefs?.length
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,17 @@ const PENDING_WARNING =
|
||||||
const FAILED_WARNING =
|
const FAILED_WARNING =
|
||||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
'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(
|
export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||||
result: SendMessageResult
|
result: SendMessageResult
|
||||||
): OpenCodeRuntimeDeliveryDiagnostics {
|
): OpenCodeRuntimeDeliveryDiagnostics {
|
||||||
|
|
@ -36,8 +47,19 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics(
|
||||||
return { warning: null, debugDetails: null };
|
return { warning: null, debugDetails: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failureReason = isFailed
|
||||||
|
? formatOpenCodeRuntimeDeliveryFailureReason(
|
||||||
|
runtimeDelivery.reason ?? runtimeDelivery.diagnostics?.[0]
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
warning: isFailed ? FAILED_WARNING : PENDING_WARNING,
|
warning:
|
||||||
|
isFailed && failureReason
|
||||||
|
? `${FAILED_WARNING} Reason: ${failureReason}`
|
||||||
|
: isFailed
|
||||||
|
? FAILED_WARNING
|
||||||
|
: PENDING_WARNING,
|
||||||
debugDetails: {
|
debugDetails: {
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
providerId: runtimeDelivery.providerId,
|
providerId: runtimeDelivery.providerId,
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,71 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
||||||
expect(observed.observedAssistantPreview).toBe('Понял');
|
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 () => {
|
it('does not keep responded live deliveries active when no inbox commit is needed', async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const direct = await store.ensurePending({
|
const direct = await store.ensurePending({
|
||||||
|
|
|
||||||
|
|
@ -895,6 +895,133 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(payload.body).not.toContain('0/4');
|
expect(payload.body).not.toContain('0/4');
|
||||||
expect(payload.body).not.toContain('did not join');
|
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', () => {
|
describe('getClaudeLogs', () => {
|
||||||
|
|
@ -4882,7 +5009,7 @@ describe('TeamProvisioningService', () => {
|
||||||
visibleMessageToolCallId: null,
|
visibleMessageToolCallId: null,
|
||||||
visibleReplyMessageId: null,
|
visibleReplyMessageId: null,
|
||||||
visibleReplyCorrelation: null,
|
visibleReplyCorrelation: null,
|
||||||
latestAssistantPreview: null,
|
latestAssistantPreview: 'Answer after observe.',
|
||||||
reason: null,
|
reason: null,
|
||||||
},
|
},
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
|
|
@ -4971,8 +5098,21 @@ describe('TeamProvisioningService', () => {
|
||||||
})
|
})
|
||||||
).resolves.toMatchObject({
|
).resolves.toMatchObject({
|
||||||
delivered: true,
|
delivered: true,
|
||||||
responsePending: false,
|
responsePending: false,
|
||||||
responseState: 'responded_plain_text',
|
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);
|
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 () => {
|
it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||||
|
|
@ -16288,6 +16627,8 @@ describe('TeamProvisioningService', () => {
|
||||||
const teamName = 'atlas-hq-source-aware-persisted';
|
const teamName = 'atlas-hq-source-aware-persisted';
|
||||||
const exactOpenCodeReason =
|
const exactOpenCodeReason =
|
||||||
'Latest assistant message msg_alice failed with APIError - Insufficient credits.';
|
'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, {
|
writeTeamMeta(teamName, {
|
||||||
providerId: 'codex',
|
providerId: 'codex',
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: 'codex-native',
|
||||||
|
|
@ -16337,7 +16678,7 @@ describe('TeamProvisioningService', () => {
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: true,
|
hardFailure: true,
|
||||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
hardFailureReason: transientBobMcpFailure,
|
||||||
lastEvaluatedAt: '2026-04-23T10:02:00.000Z',
|
lastEvaluatedAt: '2026-04-23T10:02:00.000Z',
|
||||||
},
|
},
|
||||||
jack: {
|
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 () => {
|
it('hides details again when a different runtime delivery payload arrives', async () => {
|
||||||
const { host, root } = renderWarning();
|
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