From cfc50df5a1ba9e81c4b1623f50d9d037b3e2919e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 23 Apr 2026 01:10:05 +0300 Subject: [PATCH] fix(team): preserve opencode permission-blocked launch state --- .../buildMixedPersistedLaunchSnapshot.test.ts | 75 +++++++++++++++++++ .../buildMixedPersistedLaunchSnapshot.ts | 21 +++++- .../services/team/TeamProvisioningService.ts | 1 + .../runtime/OpenCodeTeamRuntimeAdapter.ts | 4 +- .../team/runtime/TeamRuntimeAdapter.ts | 1 + .../team/OpenCodeTeamRuntimeAdapter.test.ts | 49 ++++++++++++ 6 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts index b9b9fcfe..bc4cdd53 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -230,4 +230,79 @@ describe('buildMixedPersistedLaunchSnapshot', () => { }); expect(snapshot.teamLaunchState).toBe('partial_failure'); }); + + it('preserves permission-blocked side-lane members as runtime_pending_permission', () => { + const snapshot = buildMixedPersistedLaunchSnapshot({ + teamName: 'mixed-team', + launchPhase: 'active', + updatedAt: '2026-04-22T10:05:00.000Z', + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + primaryMembers: [{ name: 'alice', providerId: 'codex', model: 'gpt-5.4', effort: 'high' }], + primaryStatuses: { + alice: { + launchState: 'confirmed_alive', + status: 'online', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + firstSpawnAcceptedAt: '2026-04-22T10:00:00.000Z', + lastHeartbeatAt: '2026-04-22T10:01:00.000Z', + updatedAt: '2026-04-22T10:05:00.000Z', + } as never, + }, + secondaryMembers: [ + { + laneId: 'secondary:opencode:bob', + member: { + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + effort: 'medium', + }, + leadDefaults: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedFastMode: 'off', + resolvedFastMode: false, + launchIdentity: null, + }, + evidence: { + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['opencode:run-1:perm_1'], + }, + }, + ], + }); + + expect(snapshot.members.bob).toMatchObject({ + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + agentToolAccepted: true, + bootstrapConfirmed: false, + pendingPermissionRequestIds: ['opencode:run-1:perm_1'], + hardFailure: false, + }); + expect(snapshot.members.bob.diagnostics).toContain('waiting for permission approval'); + expect(snapshot.summary).toEqual({ + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + expect(snapshot.teamLaunchState).toBe('partial_pending'); + }); }); diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 56b2df2b..72730b79 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -36,6 +36,7 @@ export interface MixedSecondaryLaneMemberStateInput { bootstrapConfirmed?: boolean; hardFailure?: boolean; hardFailureReason?: string; + pendingPermissionRequestIds?: string[]; diagnostics?: string[]; } | null; pendingReason?: string; @@ -46,6 +47,7 @@ function deriveMemberLaunchState(params: { bootstrapConfirmed?: boolean; runtimeAlive?: boolean; agentToolAccepted?: boolean; + pendingPermissionRequestIds?: string[]; }): MemberLaunchState { if (params.hardFailure) { return 'failed_to_start'; @@ -53,6 +55,9 @@ function deriveMemberLaunchState(params: { if (params.bootstrapConfirmed) { return 'confirmed_alive'; } + if ((params.pendingPermissionRequestIds?.length ?? 0) > 0) { + return 'runtime_pending_permission'; + } if (params.runtimeAlive || params.agentToolAccepted) { return 'runtime_pending_bootstrap'; } @@ -62,14 +67,21 @@ function deriveMemberLaunchState(params: { function buildDiagnostics( member: Pick< PersistedTeamLaunchMemberState, - 'agentToolAccepted' | 'runtimeAlive' | 'bootstrapConfirmed' | 'hardFailureReason' | 'sources' + | 'agentToolAccepted' + | 'runtimeAlive' + | 'bootstrapConfirmed' + | 'hardFailureReason' + | 'sources' + | 'pendingPermissionRequestIds' > ): string[] { const diagnostics: string[] = []; if (member.agentToolAccepted) diagnostics.push('spawn accepted'); if (member.runtimeAlive) diagnostics.push('runtime alive'); if (member.bootstrapConfirmed) diagnostics.push('late heartbeat received'); - if (member.runtimeAlive && !member.bootstrapConfirmed) { + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + diagnostics.push('waiting for permission approval'); + } else if (member.runtimeAlive && !member.bootstrapConfirmed) { diagnostics.push('waiting for teammate check-in'); } if (member.hardFailureReason) @@ -140,6 +152,7 @@ function createPrimaryLaneMemberState(params: { bootstrapConfirmed: runtime?.bootstrapConfirmed, runtimeAlive: runtime?.runtimeAlive, agentToolAccepted: runtime?.agentToolAccepted, + pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds, }), agentToolAccepted: runtime?.agentToolAccepted === true, runtimeAlive: runtime?.runtimeAlive === true, @@ -171,6 +184,7 @@ function createSecondaryLaneMemberState( bootstrapConfirmed: evidence?.bootstrapConfirmed, runtimeAlive: evidence?.runtimeAlive, agentToolAccepted: evidence?.agentToolAccepted, + pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds, }); const base: PersistedTeamLaunchMemberState = { name: params.member.name.trim(), @@ -200,6 +214,9 @@ function createSecondaryLaneMemberState( bootstrapConfirmed: evidence?.bootstrapConfirmed === true, hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', hardFailureReason, + pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length + ? [...new Set(evidence.pendingPermissionRequestIds)] + : undefined, firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined, lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 677272df..50657bf9 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -12017,6 +12017,7 @@ export class TeamProvisioningService { bootstrapConfirmed: evidenceEntry.bootstrapConfirmed, hardFailure: evidenceEntry.hardFailure, hardFailureReason: evidenceEntry.hardFailureReason, + pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds, diagnostics: evidenceEntry.diagnostics, } : null, diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index ea2f2d6d..2819ce16 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -440,7 +440,9 @@ function mapBridgeMemberToRuntimeEvidence( ? 'failed_to_start' : confirmed ? 'confirmed_alive' - : 'runtime_pending_bootstrap', + : launchState === 'permission_blocked' + ? 'runtime_pending_permission' + : 'runtime_pending_bootstrap', agentToolAccepted: confirmed || pendingRuntimeObserved, runtimeAlive: confirmed || pendingRuntimeObserved, bootstrapConfirmed: confirmed, diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index b66cf773..80847ebc 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -69,6 +69,7 @@ export interface TeamRuntimeMemberLaunchEvidence { bootstrapConfirmed: boolean; hardFailure: boolean; hardFailureReason?: string; + pendingPermissionRequestIds?: string[]; sessionId?: string; backendType?: TeamAgentRuntimeBackendType; runtimePid?: number; diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 0572c841..0e242992 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -242,6 +242,55 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }, }); }); + + it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => { + const launchOpenCodeTeam = vi.fn(async () => ({ + runId: 'run-1', + teamLaunchState: 'permission_blocked', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'permission_blocked', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({ + providerId: 'opencode' as const, + binaryPath: '/opt/homebrew/bin/opencode', + binaryFingerprint: 'version:1.14.19', + version: '1.14.19', + capabilitySnapshotId: 'cap-1', + })), + launchOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + await expect(adapter.launch(launchInput())).resolves.toMatchObject({ + teamLaunchState: 'partial_pending', + members: { + alice: { + providerId: 'opencode', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + agentToolAccepted: true, + bootstrapConfirmed: false, + hardFailure: false, + }, + }, + }); + }); }); function bridgePort(