From f237318c2921dc7f36348aaa24162cfa149e0038 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 26 May 2026 19:46:24 +0300 Subject: [PATCH] fix(agent-teams): surface OpenCode runtime permissions --- .../services/team/TeamProvisioningService.ts | 730 ++++++++++++++- .../RuntimeToolApprovalCoordinator.ts | 4 + .../bridge/OpenCodeBridgeCommandContract.ts | 3 + .../bridge/OpenCodeReadinessBridge.ts | 8 +- .../TeamProvisioningLaunchFailurePolicy.ts | 13 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 31 + .../team/runtime/TeamRuntimeAdapter.ts | 16 + src/main/services/team/runtime/index.ts | 2 + .../team/OpenCodeReadinessBridge.test.ts | 40 + .../team/OpenCodeTeamRuntimeAdapter.test.ts | 85 ++ .../RuntimeToolApprovalCoordinator.test.ts | 42 + .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 880 ++++++++++++++++++ ...eamProvisioningLaunchFailurePolicy.test.ts | 5 + .../team/TeamProvisioningService.test.ts | 84 ++ 14 files changed, 1933 insertions(+), 10 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 86390c9b..4fe7464e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -229,6 +229,7 @@ import { import { deriveMemberLaunchState, isAutoClearableLaunchFailureReason, + isCliProvisionedButNotAliveFailureReason, isNeverSpawnedDuringLaunchReason, } from './provisioning/TeamProvisioningLaunchFailurePolicy'; import { @@ -523,6 +524,8 @@ import type { TeamRuntimeLaunchResult, TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberSpec, + TeamRuntimePendingPermission, + TeamRuntimePermissionListResult, TeamRuntimePrepareResult, TeamRuntimeStopInput, } from './runtime'; @@ -540,6 +543,16 @@ type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & { ): Promise; }; +type OpenCodeRuntimePermissionListingAdapter = TeamLaunchRuntimeAdapter & { + listRuntimePermissions(input: { + teamName: string; + laneId: string; + cwd: string; + memberName?: string; + sessionId?: string | null; + }): Promise; +}; + /** * Kill a team CLI process using SIGKILL (uncatchable). * @@ -1077,6 +1090,8 @@ const OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC = 'OpenCode runtime binary is not installed or not reachable by launch preflight.'; const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC = 'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; +const OPENCODE_PENDING_PERMISSION_REQUEST_PATTERN = + /\b(?:pending permission request(?:\(s\)|s)?|permission[_ -]blocked)\b/i; function pushUniqueLine(lines: string[], line: string): void { const trimmed = line.trim(); @@ -5031,6 +5046,14 @@ export class TeamProvisioningService { return adapter as OpenCodeRuntimeMessageAdapter; } + private getOpenCodeRuntimePermissionListingAdapter(): OpenCodeRuntimePermissionListingAdapter | null { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter || typeof adapter.listRuntimePermissions !== 'function') { + return null; + } + return adapter as OpenCodeRuntimePermissionListingAdapter; + } + private resolveRuntimeRecipientProviderIdFromSources( memberName: string, config: TeamConfig | null | undefined, @@ -6261,6 +6284,19 @@ export class TeamProvisioningService { const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage( error )}`; + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName: input.teamName, + runId: input.runtimeRunId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + sessionId: ledgerRecord.runtimeSessionId, + reason, + diagnostics: [ + `opencode_direct_user_delivery_inline_observe_attempt_${inlineObserveAttempt}`, + reason, + ], + }); ledgerRecord = await input.ledger.applyObservation({ id: ledgerRecord.id, responseObservation: { @@ -6294,6 +6330,17 @@ export class TeamProvisioningService { const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation( observed.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName: input.teamName, + runId: input.runtimeRunId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + sessionId: observed.sessionId, + responseState: observedResponse?.state, + reason: observedResponse?.reason ?? observed.diagnostics[0], + diagnostics: observed.diagnostics, + }); const hadMessageSendToolError = this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord); ledgerRecord = await input.ledger.applyObservation({ id: ledgerRecord.id, @@ -6773,15 +6820,16 @@ export class TeamProvisioningService { try { const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { const previous = await this.launchStateStore.read(input.teamName).catch(() => null); - const directMember = previous?.members[input.memberName]; - const laneMemberEntry = Object.entries(previous?.members ?? {}).find( - ([, member]) => member.laneId === input.laneId - ); - const previousMember = directMember ?? laneMemberEntry?.[1]; - const previousMemberKey = directMember ? input.memberName : laneMemberEntry?.[0]; - if (!previous || !previousMember) { + const previousEntry = this.findPersistedLaunchMemberForLane({ + previousLaunchState: previous, + laneId: input.laneId, + memberName: input.memberName, + runId: input.runId, + }); + if (!previous || !previousEntry) { return false; } + const previousMember = previousEntry.member; if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) { return false; } @@ -6831,7 +6879,7 @@ export class TeamProvisioningService { launchPhase: previous.launchPhase, members: { ...previous.members, - [previousMemberKey ?? previousMember.name]: nextMember, + [previousEntry.key]: nextMember, }, updatedAt: observedAt, }); @@ -6854,6 +6902,617 @@ export class TeamProvisioningService { } } + private hasOpenCodePendingPermissionSignal(input: { + responseState?: OpenCodeMemberInboxDelivery['responseState']; + reason?: string | null; + diagnostics?: readonly string[]; + }): boolean { + if (input.responseState === 'permission_blocked') { + return true; + } + const text = [input.reason ?? undefined, ...(input.diagnostics ?? [])] + .filter((value): value is string => Boolean(value?.trim())) + .join('\n'); + return OPENCODE_PENDING_PERMISSION_REQUEST_PATTERN.test(text); + } + + private findPersistedLaunchMemberForLane(input: { + previousLaunchState: PersistedTeamLaunchSnapshot | null | undefined; + laneId: string; + memberName: string; + runId?: string | null; + }): { key: string; member: PersistedTeamLaunchMemberState } | null { + const members = input.previousLaunchState?.members; + if (!members) { + return null; + } + const laneId = input.laneId.trim() || 'primary'; + const memberName = input.memberName.trim(); + const runId = input.runId?.trim(); + const candidates = Object.entries(members).filter(([key, member]) => { + const storedName = this.resolvePersistedLaunchMemberDisplayName(key, member); + if (storedName !== memberName) { + return false; + } + if ((member.laneId?.trim() || 'primary') !== laneId) { + return false; + } + const memberRunId = member.runtimeRunId?.trim(); + return !(runId && memberRunId && memberRunId !== runId); + }); + if (candidates.length === 0) { + return null; + } + const direct = candidates.find(([key]) => key === memberName); + const [key, member] = direct ?? candidates[0]!; + return { key, member }; + } + + private resolvePersistedLaunchMemberDisplayName( + key: string, + member: PersistedTeamLaunchMemberState + ): string { + const storedName = member.name?.trim(); + const laneId = member.laneId?.trim(); + const laneMemberName = + (laneId ? this.extractOpenCodeRuntimeLaneMemberName(laneId) : null) ?? + this.extractOpenCodeRuntimeLaneMemberName(key); + if (storedName && storedName !== laneId && storedName !== key.trim()) { + return storedName; + } + return laneMemberName ?? storedName ?? key.trim(); + } + + private async maybeSyncOpenCodeRuntimePermissionsAfterDelivery(input: { + teamName: string; + runId?: string | null; + laneId: string; + memberName: string; + cwd: string; + sessionId?: string | null; + responseState?: OpenCodeMemberInboxDelivery['responseState']; + reason?: string | null; + diagnostics?: readonly string[]; + teamColor?: string; + teamDisplayName?: string; + }): Promise { + if (!input.runId?.trim()) { + return; + } + const runId = input.runId.trim(); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + if (!this.hasOpenCodePendingPermissionSignal(input)) { + return; + } + + const adapter = this.getOpenCodeRuntimePermissionListingAdapter(); + if (!adapter) { + logger.warn( + `[${input.teamName}] OpenCode runtime permission signal observed for ${input.memberName}, but permission listing bridge is unavailable.` + ); + return; + } + + let listed: { permissions: TeamRuntimePendingPermission[]; diagnostics: string[] }; + try { + listed = await adapter.listRuntimePermissions({ + teamName: input.teamName, + laneId: input.laneId, + cwd: input.cwd, + memberName: input.memberName, + sessionId: input.sessionId, + }); + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to list OpenCode runtime permissions for ${input.memberName}: ${getErrorMessage(error)}` + ); + return; + } + + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + + const pendingPermissions = listed.permissions.filter((permission) => + this.isOpenCodeRuntimePermissionForDeliveryTarget(input, permission) + ); + if (pendingPermissions.length === 0) { + const listedDiagnostics = listed.diagnostics.length + ? ` Diagnostics: ${listed.diagnostics.join(' | ')}` + : ''; + logger.warn( + `[${input.teamName}] OpenCode runtime permission signal observed for ${input.memberName}, but bridge listed no matching pending permissions.${listedDiagnostics}` + ); + return; + } + + const previousLaunchState = await this.launchStateStore.read(input.teamName).catch(() => null); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + const expectedMembers = this.resolveOpenCodeRuntimePermissionExpectedMembers({ + teamName: input.teamName, + runId, + laneId: input.laneId, + memberName: input.memberName, + cwd: input.cwd, + previousLaunchState, + }); + const permissionsByMember = this.groupOpenCodeRuntimePermissionsByMember({ + permissions: pendingPermissions, + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + runId, + sessionId: input.sessionId, + expectedMembers, + previousLaunchState, + }); + if (permissionsByMember.size === 0) { + return; + } + + await this.persistOpenCodeRuntimePendingPermissions({ + ...input, + permissionsByMember, + previousLaunchState, + }); + if (this.getTrackedRunId(input.teamName) !== runId) { + return; + } + this.syncOpenCodeRuntimePermissionSpawnStatuses({ + ...input, + permissionsByMember, + }); + + const members: Record = {}; + for (const [memberName, permissions] of permissionsByMember) { + members[memberName] = this.buildOpenCodePermissionPendingEvidence({ + teamName: input.teamName, + laneId: input.laneId, + memberName, + permissions, + runId, + sessionId: input.sessionId, + previousLaunchState, + }); + } + + this.syncOpenCodeRuntimeToolApprovals({ + teamName: input.teamName, + runId: input.runId, + laneId: input.laneId, + cwd: input.cwd, + members, + expectedMembers, + memberNames: Array.from(permissionsByMember.keys()), + teamColor: input.teamColor, + teamDisplayName: input.teamDisplayName, + }); + } + + private isOpenCodeRuntimePermissionForDeliveryTarget( + input: { + laneId: string; + sessionId?: string | null; + }, + permission: TeamRuntimePendingPermission + ): boolean { + const permissionSessionId = permission.sessionId?.trim(); + const inputSessionId = input.sessionId?.trim(); + if (permissionSessionId && inputSessionId) { + return permissionSessionId === inputSessionId; + } + return true; + } + + private resolveOpenCodeRuntimePermissionExpectedMembers(input: { + teamName: string; + runId: string; + laneId: string; + memberName: string; + cwd: string; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): TeamRuntimeMemberSpec[] { + const members = new Map(); + for (const [memberKey, member] of Object.entries(input.previousLaunchState?.members ?? {})) { + if (member.providerId !== 'opencode') continue; + if ((member.laneId?.trim() || 'primary') !== input.laneId) continue; + const memberRunId = member.runtimeRunId?.trim(); + if (memberRunId && memberRunId !== input.runId) continue; + const displayName = this.resolvePersistedLaunchMemberDisplayName(memberKey, member); + members.set(displayName, { + name: displayName, + role: undefined, + workflow: undefined, + isolation: undefined, + providerId: 'opencode', + model: member.model, + effort: member.effort, + cwd: member.cwd?.trim() || input.cwd, + }); + } + + const trackedRunId = this.getTrackedRunId(input.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + for (const member of [ + ...(trackedRun?.allEffectiveMembers ?? []), + ...(trackedRun?.effectiveMembers ?? []), + ]) { + if (member.providerId !== 'opencode' || members.has(member.name)) continue; + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: resolveTeamProviderId(trackedRun?.request.providerId), + member: { + name: member.name, + providerId: 'opencode', + }, + }); + if (laneIdentity.laneId !== input.laneId) continue; + members.set(member.name, { + name: member.name, + role: member.role, + workflow: member.workflow, + isolation: member.isolation === 'worktree' ? 'worktree' : undefined, + providerId: 'opencode', + model: member.model, + effort: member.effort, + cwd: member.cwd?.trim() || input.cwd, + }); + } + const runtimeRun = this.runtimeAdapterRunByTeam.get(input.teamName); + if ( + (input.laneId.trim() || 'primary') === 'primary' && + runtimeRun?.runId === input.runId && + runtimeRun.providerId === 'opencode' + ) { + for (const [memberKey, evidence] of Object.entries(runtimeRun.members ?? {})) { + const memberName = evidence.memberName?.trim() || memberKey; + if (!memberName || members.has(memberName)) continue; + members.set(memberName, { + name: memberName, + providerId: 'opencode', + model: evidence.model, + cwd: input.cwd, + }); + } + } + + if (!members.has(input.memberName)) { + members.set(input.memberName, { + name: input.memberName, + providerId: 'opencode', + cwd: input.cwd, + }); + } + return Array.from(members.values()); + } + + private groupOpenCodeRuntimePermissionsByMember(input: { + permissions: readonly TeamRuntimePendingPermission[]; + teamName: string; + laneId: string; + memberName: string; + runId: string; + sessionId?: string | null; + expectedMembers: readonly TeamRuntimeMemberSpec[]; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): Map { + const sessionToMember = new Map(); + for (const [memberName, member] of Object.entries(input.previousLaunchState?.members ?? {})) { + if ((member.laneId?.trim() || 'primary') !== input.laneId) continue; + const memberRunId = member.runtimeRunId?.trim(); + if (memberRunId && memberRunId !== input.runId) continue; + const sessionId = member.runtimeSessionId?.trim(); + if (sessionId) { + sessionToMember.set( + sessionId, + this.resolvePersistedLaunchMemberDisplayName(memberName, member) + ); + } + } + const trackedRunId = this.getTrackedRunId(input.teamName); + const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null; + const lane = trackedRun?.mixedSecondaryLanes?.find( + (candidate) => candidate.laneId === input.laneId + ); + for (const [memberName, evidence] of Object.entries(lane?.result?.members ?? {})) { + const sessionId = evidence.sessionId?.trim(); + if (sessionId) { + sessionToMember.set(sessionId, evidence.memberName?.trim() || memberName); + } + } + const runtimeRun = this.runtimeAdapterRunByTeam.get(input.teamName); + if ( + (input.laneId.trim() || 'primary') === 'primary' && + runtimeRun?.runId === input.runId && + runtimeRun.providerId === 'opencode' + ) { + for (const [memberName, evidence] of Object.entries(runtimeRun.members ?? {})) { + const sessionId = evidence.sessionId?.trim(); + if (sessionId) { + sessionToMember.set(sessionId, evidence.memberName?.trim() || memberName); + } + } + } + + const singleExpectedMember = + input.expectedMembers.length === 1 ? input.expectedMembers[0]?.name : undefined; + const inputSessionId = input.sessionId?.trim(); + const result = new Map(); + for (const permission of input.permissions) { + const permissionSessionId = permission.sessionId?.trim(); + const memberName = permissionSessionId + ? (sessionToMember.get(permissionSessionId) ?? + (inputSessionId === permissionSessionId ? input.memberName : undefined) ?? + singleExpectedMember) + : (singleExpectedMember ?? input.memberName); + if (!memberName) { + continue; + } + result.set(memberName, [...(result.get(memberName) ?? []), permission]); + } + return result; + } + + private buildOpenCodePermissionPendingEvidence(input: { + teamName: string; + laneId: string; + memberName: string; + permissions: readonly TeamRuntimePendingPermission[]; + runId: string; + sessionId?: string | null; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): TeamRuntimeMemberLaunchEvidence { + const previous = this.findPersistedLaunchMemberForLane({ + previousLaunchState: input.previousLaunchState, + laneId: input.laneId, + memberName: input.memberName, + runId: input.runId, + })?.member; + const ids = Array.from(new Set(input.permissions.map((permission) => permission.requestId))); + const sessionId = previous?.runtimeSessionId ?? input.sessionId?.trim() ?? undefined; + return { + memberName: input.memberName, + providerId: 'opencode', + ...(previous?.model ? { model: previous.model } : {}), + launchState: + previous?.launchState === 'confirmed_alive' || previous?.bootstrapConfirmed + ? 'confirmed_alive' + : 'runtime_pending_permission', + agentToolAccepted: previous?.agentToolAccepted ?? true, + runtimeAlive: previous?.runtimeAlive ?? false, + bootstrapConfirmed: previous?.bootstrapConfirmed ?? false, + hardFailure: false, + pendingPermissionRequestIds: ids, + pendingApprovals: [...input.permissions], + pendingPermissions: [...input.permissions], + ...(sessionId ? { sessionId } : {}), + livenessKind: previous?.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + 'OpenCode runtime permission request discovered after delivery was blocked.', + ...(previous?.diagnostics ?? []), + ], + }; + } + + private async persistOpenCodeRuntimePendingPermissions(input: { + teamName: string; + runId?: string | null; + laneId: string; + sessionId?: string | null; + permissionsByMember: ReadonlyMap; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): Promise { + if (!input.previousLaunchState) { + return; + } + const observedAt = nowIso(); + try { + const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { + const incomingRunId = input.runId?.trim(); + if (incomingRunId && this.getTrackedRunId(input.teamName) !== incomingRunId) { + return false; + } + const previous = await this.launchStateStore.read(input.teamName).catch(() => null); + if (!previous) { + return false; + } + let didChange = false; + const members = { ...previous.members }; + for (const [memberName, permissions] of input.permissionsByMember) { + const previousEntry = this.findPersistedLaunchMemberForLane({ + previousLaunchState: previous, + laneId: input.laneId, + memberName, + runId: input.runId, + }); + if (!previousEntry || previousEntry.member.providerId !== 'opencode') { + continue; + } + const previousMember = previousEntry.member; + if ((previousMember.laneId?.trim() || 'primary') !== input.laneId) { + continue; + } + const previousRunId = previousMember.runtimeRunId?.trim(); + if (previousRunId && incomingRunId && previousRunId !== incomingRunId) { + continue; + } + const previousSessionId = previousMember.runtimeSessionId?.trim(); + const incomingSessionId = input.sessionId?.trim(); + if (previousSessionId && incomingSessionId && previousSessionId !== incomingSessionId) { + continue; + } + const pendingPermissionRequestIds = Array.from( + new Set(permissions.map((permission) => permission.requestId.trim()).filter(Boolean)) + ); + const nextMember: PersistedTeamLaunchMemberState = { + ...previousMember, + name: memberName, + launchState: + previousMember.launchState === 'confirmed_alive' || previousMember.bootstrapConfirmed + ? 'confirmed_alive' + : 'runtime_pending_permission', + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds, + ...(incomingRunId ? { runtimeRunId: incomingRunId } : {}), + ...(incomingSessionId && !previousSessionId + ? { runtimeSessionId: incomingSessionId } + : {}), + livenessKind: previousMember.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + lastEvaluatedAt: observedAt, + diagnostics: mergeRuntimeDiagnostics( + previousMember.diagnostics, + ['waiting for permission approval'], + previousMember.runtimeDiagnostic + ), + }; + if ( + previousMember.name === nextMember.name && + previousMember.launchState === nextMember.launchState && + previousMember.hardFailure === nextMember.hardFailure && + previousMember.hardFailureReason === nextMember.hardFailureReason && + previousMember.pendingPermissionRequestIds?.join('\0') === + nextMember.pendingPermissionRequestIds?.join('\0') && + previousMember.runtimeRunId === nextMember.runtimeRunId && + previousMember.runtimeSessionId === nextMember.runtimeSessionId && + previousMember.livenessKind === nextMember.livenessKind && + previousMember.runtimeDiagnostic === nextMember.runtimeDiagnostic && + previousMember.runtimeDiagnosticSeverity === nextMember.runtimeDiagnosticSeverity + ) { + continue; + } + members[previousEntry.key] = nextMember; + didChange = true; + } + if (!didChange) { + return false; + } + const nextSnapshot = createPersistedLaunchSnapshot({ + teamName: previous.teamName, + expectedMembers: previous.expectedMembers, + bootstrapExpectedMembers: previous.bootstrapExpectedMembers, + leadSessionId: previous.leadSessionId, + launchPhase: previous.launchPhase, + members, + updatedAt: observedAt, + }); + await this.writeLaunchStateSnapshotNow(input.teamName, nextSnapshot); + return true; + }); + if (changed) { + this.invalidateRuntimeSnapshotCaches(input.teamName); + for (const memberName of input.permissionsByMember.keys()) { + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + ...(input.runId ? { runId: input.runId } : {}), + detail: memberName, + }); + } + } + } catch (error) { + logger.debug( + `[${input.teamName}] Failed to persist OpenCode pending runtime permissions: ${getErrorMessage(error)}` + ); + } + } + + private syncOpenCodeRuntimePermissionSpawnStatuses(input: { + teamName: string; + runId?: string | null; + laneId: string; + permissionsByMember: ReadonlyMap; + }): void { + const trackedRunId = this.getTrackedRunId(input.teamName); + const run = trackedRunId ? this.runs.get(trackedRunId) : null; + if (!run || run.runId !== input.runId) { + return; + } + const updatedAt = nowIso(); + for (const [memberName, permissions] of input.permissionsByMember) { + const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); + const lane = run.mixedSecondaryLanes?.find((candidate) => candidate.laneId === input.laneId); + const laneEvidence = lane?.result?.members?.[memberName]; + const pendingPermissionRequestIds = Array.from( + new Set(permissions.map((permission) => permission.requestId.trim()).filter(Boolean)) + ); + const joinedPendingPermissionRequestIds = pendingPermissionRequestIds.join('\0'); + const laneEvidenceNeedsUpdate = Boolean( + lane?.result && + laneEvidence && + (laneEvidence.pendingPermissionRequestIds?.join('\0') !== + joinedPendingPermissionRequestIds || + laneEvidence.runtimeDiagnostic !== + 'OpenCode runtime is waiting for permission approval' || + laneEvidence.runtimeDiagnosticSeverity !== 'warning') + ); + const next: MemberSpawnStatusEntry = { + ...prev, + status: prev.bootstrapConfirmed || laneEvidence?.bootstrapConfirmed ? 'online' : 'waiting', + launchState: prev.launchState, + agentToolAccepted: true, + runtimeAlive: prev.runtimeAlive === true || laneEvidence?.runtimeAlive === true, + bootstrapConfirmed: + prev.bootstrapConfirmed === true || laneEvidence?.bootstrapConfirmed === true, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + pendingPermissionRequestIds, + livenessKind: prev.livenessKind ?? laneEvidence?.livenessKind ?? 'permission_blocked', + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + updatedAt, + }; + next.launchState = deriveMemberLaunchState(next); + if ( + prev.pendingPermissionRequestIds?.join('\0') === joinedPendingPermissionRequestIds && + prev.launchState === next.launchState && + prev.runtimeDiagnostic === next.runtimeDiagnostic && + !laneEvidenceNeedsUpdate + ) { + continue; + } + run.memberSpawnStatuses.set(memberName, next); + if (lane?.result && laneEvidence) { + lane.result = { + ...lane.result, + members: { + ...lane.result.members, + [memberName]: { + ...laneEvidence, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds, + pendingApprovals: [...permissions], + pendingPermissions: [...permissions], + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + runtimeDiagnosticSeverity: 'warning', + diagnostics: + mergeRuntimeDiagnostics( + laneEvidence.diagnostics, + ['waiting for permission approval'], + laneEvidence.runtimeDiagnostic + ) ?? [], + }, + }, + }; + } + if (this.isCurrentTrackedRun(run)) { + this.emitMemberSpawnChange(run, memberName); + } + } + if (run.isLaunch) { + void this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); + } + } + private logOpenCodePromptDeliveryEvent( event: string, record: OpenCodePromptDeliveryLedgerRecord, @@ -7678,6 +8337,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: result.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? result.diagnostics[0], + diagnostics: result.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); return { delivered: result.ok, accepted: result.ok, @@ -7950,6 +8622,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( observed.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: observed.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? observed.diagnostics[0], + diagnostics: observed.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); ledgerRecord = await ledger.applyObservation({ id: ledgerRecord.id, responseObservation: responseObservation ?? { @@ -8176,6 +8861,17 @@ export class TeamProvisioningService { }); } catch (error) { const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(error)}`; + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + reason: diagnostic, + diagnostics: [diagnostic], + teamColor: config?.color, + teamDisplayName: config?.name, + }); if (ledgerRecord && ledger) { ledgerRecord = await ledger.applyDeliveryResult({ id: ledgerRecord.id, @@ -8256,6 +8952,19 @@ export class TeamProvisioningService { const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( result.responseObservation ); + await this.maybeSyncOpenCodeRuntimePermissionsAfterDelivery({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + cwd, + sessionId: result.sessionId, + responseState: responseObservation?.state, + reason: responseObservation?.reason ?? result.diagnostics[0], + diagnostics: result.diagnostics, + teamColor: config?.color, + teamDisplayName: config?.name, + }); const promptAcceptedByRuntimeIdentity = Boolean( result.ok && result.runtimePromptMessageId?.trim() ); @@ -28752,6 +29461,8 @@ export class TeamProvisioningService { current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic; const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason); + const requiresConfirmedBootstrapToClearFailure = + isCliProvisionedButNotAliveFailureReason(initialFailureReason); current.runtimeAlive = observedRuntimeAlive; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; current.livenessKind = runtimeMetadata?.[1].livenessKind; @@ -28775,6 +29486,7 @@ export class TeamProvisioningService { current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; if ( hadAutoClearableFailure && + !requiresConfirmedBootstrapToClearFailure && (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) ) { current.hardFailure = false; @@ -31445,6 +32157,7 @@ export class TeamProvisioningService { cwd: string; members: Record; expectedMembers: TeamRuntimeMemberSpec[]; + memberNames?: readonly string[]; teamColor?: string; teamDisplayName?: string; }): void { @@ -31454,6 +32167,7 @@ export class TeamProvisioningService { teamName: input.teamName, runId: input.runId, laneId: input.laneId, + memberNames: input.memberNames, providerId: 'opencode', }, entries diff --git a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts index eb921f39..d69a42c4 100644 --- a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts +++ b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts @@ -64,6 +64,7 @@ export interface RuntimeToolApprovalSyncScope { teamName: string; runId: string; laneId?: string; + memberNames?: readonly string[]; providerId?: RuntimeApprovalProviderId; } @@ -405,6 +406,9 @@ export class RuntimeToolApprovalCoordinator { if (scope.laneId && entry.laneId !== scope.laneId) { return false; } + if (scope.memberNames?.length && !scope.memberNames.includes(entry.memberName)) { + return false; + } if (scope.providerId && entry.providerId !== scope.providerId) { return false; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 7c9c36c5..dc898ffb 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -160,11 +160,14 @@ export interface OpenCodeListRuntimePermissionsCommandBody { teamId: string; teamName: string; laneId?: string; + memberName?: string; + sessionId?: string | null; projectPath?: string; } export interface OpenCodeListRuntimePermissionsCommandData { permissions: OpenCodeRuntimePermissionCommandData[]; + diagnostics?: string[]; } export interface OpenCodeCleanupHostsCommandBody { diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index fd67f0fa..3568e35e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -257,7 +257,13 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { if (result.ok) { return result.data; } - return { permissions: [] }; + return { + permissions: [], + diagnostics: [ + `OpenCode runtime permission list bridge failed: ${result.error.kind}: ${result.error.message}`, + ...result.diagnostics.map(formatDiagnosticEvent), + ], + }; } async cleanupOpenCodeHosts( diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts index 58165db5..2f6366c0 100644 --- a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts +++ b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts @@ -37,6 +37,16 @@ export function isProcessTableUnavailableFailureReason(reason?: string): boolean ); } +export function isCliProvisionedButNotAliveFailureReason(reason?: string): boolean { + const text = reason?.trim(); + if (!text) { + return false; + } + return /^CLI process exited \(code (?:unknown|\d+|\?)\) [\u2014-] team provisioned but not alive$/i.test( + text + ); +} + export function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null { const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim()); const baseReason = match?.[1]?.trim(); @@ -53,7 +63,8 @@ function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean { isBootstrapMcpResourceReadFailureReason(reason) || isBootstrapCheckInTimeoutFailureReason(reason) || isBootstrapInstructionPromptFailureReason(reason) || - isLaunchCleanupBootstrapIncompleteFailureReason(reason) + isLaunchCleanupBootstrapIncompleteFailureReason(reason) || + isCliProvisionedButNotAliveFailureReason(reason) ); } diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index fe77dbf5..e986e5c3 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -5,6 +5,8 @@ import type { OpenCodeBridgeRuntimeSnapshot, OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandData, + OpenCodeListRuntimePermissionsCommandBody, + OpenCodeListRuntimePermissionsCommandData, OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandData, OpenCodeReconcileTeamCommandBody, @@ -24,6 +26,8 @@ import type { TeamRuntimeMemberStopEvidence, TeamRuntimePendingPermission, TeamRuntimePermissionAnswerInput, + TeamRuntimePermissionListInput, + TeamRuntimePermissionListResult, TeamRuntimePrepareResult, TeamRuntimeReconcileInput, TeamRuntimeReconcileResult, @@ -59,6 +63,9 @@ export interface OpenCodeTeamRuntimeBridgePort { answerOpenCodeRuntimePermission?( input: OpenCodeAnswerPermissionCommandBody ): Promise; + listOpenCodeRuntimePermissions?( + input: OpenCodeListRuntimePermissionsCommandBody + ): Promise; } export interface OpenCodeTeamRuntimeMessageInput { @@ -599,6 +606,30 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { ); } + async listRuntimePermissions( + input: TeamRuntimePermissionListInput + ): Promise { + if (!this.bridge.listOpenCodeRuntimePermissions) { + return { + permissions: [], + diagnostics: ['OpenCode runtime permission list bridge is not registered.'], + }; + } + + const data = await this.bridge.listOpenCodeRuntimePermissions({ + teamId: input.teamName, + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + sessionId: input.sessionId, + projectPath: input.cwd, + }); + return { + permissions: normalizeOpenCodeRuntimePendingPermissions(data.permissions) ?? [], + diagnostics: data.diagnostics ?? [], + }; + } + async stop(input: TeamRuntimeStopInput): Promise { if (this.bridge.stopOpenCodeTeam) { const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 22a2d909..d048f88e 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -56,6 +56,19 @@ export interface TeamRuntimePermissionAnswerInput { previousLaunchState: PersistedTeamLaunchSnapshot | null; } +export interface TeamRuntimePermissionListInput { + teamName: string; + laneId?: string; + cwd?: string; + memberName?: string; + sessionId?: string | null; +} + +export interface TeamRuntimePermissionListResult { + permissions: TeamRuntimePendingPermission[]; + diagnostics: string[]; +} + export interface TeamRuntimeLaunchInput { runId: string; teamName: string; @@ -206,6 +219,9 @@ export interface TeamLaunchRuntimeAdapter { answerRuntimePermission?( input: TeamRuntimePermissionAnswerInput ): Promise; + listRuntimePermissions?( + input: TeamRuntimePermissionListInput + ): Promise; } export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index bbf41d8f..4baec494 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -14,6 +14,8 @@ export type { TeamRuntimeMemberStopEvidence, TeamRuntimePendingApproval, TeamRuntimePendingPermission, + TeamRuntimePermissionListInput, + TeamRuntimePermissionListResult, TeamRuntimePrepareFailure, TeamRuntimePrepareResult, TeamRuntimePrepareSuccess, diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index c73e2ff5..fbc480bc 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -202,6 +202,46 @@ describe('OpenCodeReadinessBridge', () => { ); }); + it('preserves diagnostics when runtime permission listing bridge fails', async () => { + const executor = fakeExecutor( + bridgeCommandFailure({ + command: 'opencode.listRuntimePermissions', + requestId: 'permission-list-req-1', + kind: 'timeout', + message: 'permission list timed out', + }) + ); + const bridge = new OpenCodeReadinessBridge(executor); + + await expect( + bridge.listOpenCodeRuntimePermissions({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'primary', + projectPath: '/repo', + }) + ).resolves.toEqual({ + permissions: [], + diagnostics: [ + 'OpenCode runtime permission list bridge failed: timeout: permission list timed out', + ], + }); + + expect(executor.execute).toHaveBeenCalledWith( + 'opencode.listRuntimePermissions', + { + teamId: 'team-a', + teamName: 'team-a', + laneId: 'primary', + projectPath: '/repo', + }, + { + cwd: '/repo', + timeoutMs: 30_000, + } + ); + }); + it('gives observeMessageDelivery enough time for OpenCode plain-text fallback reconciliation', async () => { const executor = fakeExecutor( bridgeCommandSuccess({ diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index 50e98f0d..41d5f240 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -1586,6 +1586,91 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ).rejects.toThrow('OpenCode permission answer bridge is not registered.'); }); + it('lists OpenCode runtime permissions through the bridge', async () => { + const listOpenCodeRuntimePermissions = vi.fn< + NonNullable + >(async () => ({ + permissions: [ + { + requestId: 'perm-1', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + { + requestId: 'perm-1', + sessionId: 'session-alice', + tool: 'bash', + title: 'Duplicate', + kind: 'tool', + }, + { + requestId: ' ', + sessionId: null, + tool: null, + title: null, + kind: null, + }, + ], + diagnostics: ['permission list recovered from bridge warning'], + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + listOpenCodeRuntimePermissions, + }) + ); + + await expect( + adapter.listRuntimePermissions({ + teamName: 'team-a', + laneId: 'secondary:opencode:alice', + memberName: 'alice', + sessionId: 'session-alice', + cwd: '/repo', + }) + ).resolves.toEqual({ + permissions: [ + { + providerId: 'opencode', + requestId: 'perm-1', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ], + diagnostics: ['permission list recovered from bridge warning'], + }); + expect(listOpenCodeRuntimePermissions).toHaveBeenCalledWith({ + teamId: 'team-a', + teamName: 'team-a', + laneId: 'secondary:opencode:alice', + memberName: 'alice', + sessionId: 'session-alice', + projectPath: '/repo', + }); + }); + + it('returns a diagnostic when the OpenCode runtime permission list bridge is unavailable', async () => { + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true })) + ); + + await expect( + adapter.listRuntimePermissions({ + teamName: 'team-a', + laneId: 'primary', + cwd: '/repo', + }) + ).resolves.toEqual({ + permissions: [], + diagnostics: ['OpenCode runtime permission list bridge is not registered.'], + }); + }); + it('does not mark created bridge members without runtimePid as runtimeAlive', async () => { const launchOpenCodeTeam = vi.fn( async () => diff --git a/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts index 9f6f5107..0eb561a8 100644 --- a/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts +++ b/test/main/services/team/RuntimeToolApprovalCoordinator.test.ts @@ -190,6 +190,48 @@ describe('RuntimeToolApprovalCoordinator', () => { }); }); + it('keeps other member approvals when runtime sync is scoped to one member', () => { + const alice = approvalEntry(); + const bob = approvalEntry({ + providerRequestId: 'perm-bob', + memberName: 'bob', + approval: { + requestId: 'opencode:run-1:perm-bob', + runId: 'run-1', + teamName: 'team-a', + providerId: 'opencode', + source: 'bob', + toolName: 'Bash', + toolInput: { command: 'pnpm test' }, + receivedAt: '2026-05-22T10:00:00.000Z', + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'bob', + providerRequestId: 'perm-bob', + sessionId: 'ses-bob', + }, + }, + }); + coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [alice, bob]); + + coordinator.sync( + { teamName: 'team-a', runId: 'run-1', laneId: 'primary', memberNames: ['alice'] }, + [alice] + ); + + expect(coordinator.get('team-a', 'opencode:run-1:perm-bob')).toBe(bob); + expect(coordinator.size('team-a')).toBe(2); + expect( + events.some( + (event) => + 'autoResolved' in event && + event.requestId === 'opencode:run-1:perm-bob' && + event.reason === 'runtime_resolved' + ) + ).toBe(false); + }); + it('rejects stale UI responses by run id', async () => { coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 47bad9c3..904aeddd 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -26,7 +26,10 @@ import { type TeamRuntimeLaunchResult, type TeamRuntimeMemberLaunchEvidence, type TeamRuntimeMemberSpec, + type TeamRuntimePendingPermission, type TeamRuntimePermissionAnswerInput, + type TeamRuntimePermissionListInput, + type TeamRuntimePermissionListResult, type TeamRuntimePrepareResult, type TeamRuntimeReconcileInput, type TeamRuntimeReconcileResult, @@ -10368,6 +10371,222 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs[0]?.runId).not.toBe(staleRun.runId); }); + it('surfaces mixed OpenCode side-lane delivery permission blocks through shared approvals', async () => { + const teamName = 'mixed-opencode-delivery-permission-approval-safe-e2e'; + await writeMixedTeamConfig({ + teamName, + projectPath, + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' }); + await writeMembersMeta(teamName, { + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('secondary:opencode:bob', [ + { + providerId: 'opencode', + requestId: 'perm-bob-delivery', + sessionId: 'session-bob', + tool: 'bash', + title: 'Run pnpm test', + kind: 'tool', + raw: { patterns: ['pnpm test'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + addGeminiPrimaryToMixedRun(run); + run.runId = `run-${teamName}-current`; + await markMixedOpenCodeLaneConfirmedForTest(run, 'bob'); + trackLiveRun(svc, run); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'trigger a side-lane permission-blocked delivery', + messageId: 'msg-mixed-opencode-permission-blocked', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + expect(adapter.permissionListInputs).toEqual([ + { + teamName, + laneId: 'secondary:opencode:bob', + cwd: projectPath, + memberName: 'bob', + sessionId: 'session-bob', + }, + ]); + const approval = approvalEvents.find( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approval).toMatchObject({ + requestId: `opencode:${run.runId}:perm-bob-delivery`, + runId: run.runId, + teamName, + providerId: 'opencode', + source: 'bob', + toolName: 'Bash', + toolInput: { + provider: 'opencode', + providerRequestId: 'perm-bob-delivery', + command: 'pnpm test', + }, + runtimePermission: { + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + providerRequestId: 'perm-bob-delivery', + }, + }); + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + pendingPermissionRequestIds: ['perm-bob-delivery'], + runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval', + }); + await waitForCondition(async () => { + let persisted: { members?: Record }; + try { + persisted = JSON.parse( + await fs.readFile(path.join(getTeamsBasePath(), teamName, 'launch-state.json'), 'utf8') + ) as typeof persisted; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + return persisted.members?.bob?.pendingPermissionRequestIds?.includes('perm-bob-delivery') === true; + }); + }); + + it('persists mixed OpenCode permissions to the matching lane member when persisted keys diverge', async () => { + const teamName = 'mixed-opencode-delivery-permission-lane-key-safe-e2e'; + await writeMixedTeamConfig({ + teamName, + projectPath, + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' }); + await writeMembersMeta(teamName, { + includeGeminiPrimary: true, + primaryProviderId: 'anthropic', + }); + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('secondary:opencode:bob', [ + { + providerId: 'opencode', + requestId: 'perm-bob-lane-key', + sessionId: 'session-bob', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' }); + addGeminiPrimaryToMixedRun(run); + run.runId = `run-${teamName}-current`; + run.isLaunch = false; + await markMixedOpenCodeLaneConfirmedForTest(run, 'bob'); + trackLiveRun(svc, run); + + const teamDir = path.join(getTeamsBasePath(), teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'reviewer', 'bob'], + leadSessionId: 'lead-session', + launchPhase: 'active', + members: { + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/old-primary', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'anthropic', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: run.runId, + runtimeSessionId: 'session-primary-bob', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + 'secondary:opencode:bob': { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimeRunId: run.runId, + runtimeSessionId: 'session-bob', + livenessKind: 'confirmed_bootstrap', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + }) + )}\n`, + 'utf8' + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'trigger a side-lane permission-blocked delivery with lane-keyed state', + messageId: 'msg-mixed-opencode-permission-lane-key', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + const approvals = approvalEvents.filter( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approvals).toEqual([ + expect.objectContaining({ + requestId: `opencode:${run.runId}:perm-bob-lane-key`, + source: 'bob', + }), + ]); + const persisted = JSON.parse( + await fs.readFile(path.join(teamDir, 'launch-state.json'), 'utf8') + ) as { + members?: Record; + }; + expect(persisted.members?.bob?.pendingPermissionRequestIds).toBeUndefined(); + expect( + persisted.members?.['secondary:opencode:bob']?.pendingPermissionRequestIds + ).toEqual(['perm-bob-lane-key']); + }); + it('refreshes stale mixed OpenCode secondary session evidence before direct delivery when MCP transport changed', async () => { const teamName = 'mixed-opencode-secondary-transport-refresh-safe-e2e'; await writeMixedTeamConfig({ @@ -10863,6 +11082,557 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId); }); + it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => { + const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e'; + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-delivery', + sessionId: null, + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger a permission-blocked delivery', + messageId: 'msg-pure-opencode-permission-blocked', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + expect(adapter.permissionListInputs).toEqual([ + { + teamName, + laneId: 'primary', + cwd: projectPath, + memberName: 'alice', + sessionId: 'session-alice', + }, + ]); + const approval = approvalEvents.find( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approval).toMatchObject({ + requestId: `opencode:${launch.runId}:perm-alice-delivery`, + runId: launch.runId, + teamName, + providerId: 'opencode', + source: 'alice', + toolName: 'Bash', + toolInput: { + provider: 'opencode', + providerRequestId: 'perm-alice-delivery', + command: 'git status', + }, + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-alice-delivery', + }, + }); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.statuses.alice?.pendingPermissionRequestIds).toEqual([ + 'perm-alice-delivery', + ]); + }); + + it('keeps other primary OpenCode approvals when a delivery-blocked member syncs permissions', async () => { + const teamName = 'pure-opencode-delivery-permission-member-scope-safe-e2e'; + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter('partial_pending', { + alice: 'confirmed', + bob: 'permission', + }); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-delivery', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: false, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + ], + }, + () => undefined + ); + + expect(approvalEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: `opencode:${launch.runId}:perm-bob`, + source: 'bob', + }), + ]) + ); + approvalEvents.length = 0; + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger alice permission-blocked delivery', + messageId: 'msg-pure-opencode-permission-blocked-member-scope', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + expect(approvalEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: `opencode:${launch.runId}:perm-alice-delivery`, + source: 'alice', + }), + ]) + ); + expect(approvalEvents).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + autoResolved: true, + requestId: `opencode:${launch.runId}:perm-bob`, + reason: 'runtime_resolved', + }), + ]) + ); + + await svc.respondToToolApproval(teamName, launch.runId!, `opencode:${launch.runId}:perm-bob`, true); + expect(adapter.permissionAnswerInputs).toEqual([ + expect.objectContaining({ + runId: launch.runId, + teamName, + laneId: 'primary', + memberName: 'bob', + requestId: 'perm-bob', + decision: 'allow', + }), + ]); + }); + + it('does not surface stale OpenCode delivery permissions after the tracked run changes during listing', async () => { + const teamName = 'pure-opencode-delivery-permission-stale-run-safe-e2e'; + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-stale-run', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ]); + let releaseList!: () => void; + let markListStarted!: () => void; + const listStarted = new Promise((resolve) => { + markListStarted = resolve; + }); + const releaseListPromise = new Promise((resolve) => { + releaseList = resolve; + }); + const originalListRuntimePermissions = adapter.listRuntimePermissions.bind(adapter); + vi.spyOn(adapter, 'listRuntimePermissions').mockImplementation(async (input) => { + markListStarted(); + await releaseListPromise; + return originalListRuntimePermissions(input); + }); + + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + const delivery = svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger permission listing while the run changes', + messageId: 'msg-pure-opencode-permission-stale-run', + }); + await listStarted; + (svc as any).provisioningRunByTeam.set(teamName, `${launch.runId}-replacement`); + (svc as any).aliveRunByTeam.set(teamName, `${launch.runId}-replacement`); + releaseList(); + + await expect(delivery).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + expect(approvalEvents).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: `opencode:${launch.runId}:perm-alice-stale-run`, + }), + ]) + ); + }); + + it('does not surface stale OpenCode delivery permissions after the tracked run changes during persisted-state read', async () => { + const teamName = 'pure-opencode-delivery-permission-stale-read-safe-e2e'; + const adapter = new PermissionBlockedOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-stale-read', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + ]); + const originalListRuntimePermissions = adapter.listRuntimePermissions.bind(adapter); + let replaceRunOnNextLaunchStateRead = false; + vi.spyOn(adapter, 'listRuntimePermissions').mockImplementation(async (input) => { + const result = await originalListRuntimePermissions(input); + replaceRunOnNextLaunchStateRead = true; + return result; + }); + + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + const launchStateStore = (svc as any).launchStateStore as { + read(teamName: string): Promise; + }; + const originalRead = launchStateStore.read.bind(launchStateStore); + vi.spyOn(launchStateStore, 'read').mockImplementation(async (readTeamName) => { + const snapshot = await originalRead(readTeamName); + if (replaceRunOnNextLaunchStateRead && readTeamName === teamName) { + replaceRunOnNextLaunchStateRead = false; + (svc as any).provisioningRunByTeam.set(teamName, `${launch.runId}-replacement`); + (svc as any).aliveRunByTeam.set(teamName, `${launch.runId}-replacement`); + } + return snapshot; + }); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger permission listing before the persisted state read changes run', + messageId: 'msg-pure-opencode-permission-stale-read', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + expect(approvalEvents).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + requestId: `opencode:${launch.runId}:perm-alice-stale-read`, + }), + ]) + ); + }); + + it('surfaces OpenCode permissions when inline delivery observe hits a pending request', async () => { + const teamName = 'pure-opencode-inline-observe-permission-approval-safe-e2e'; + const adapter = new PermissionBlockedInlineObserveOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-inline-observe', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run printf', + kind: 'tool', + raw: { patterns: ['printf inline-observe'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger inline observe permission block', + messageId: 'msg-pure-opencode-inline-observe-permission-blocked', + replyRecipient: 'user', + actionMode: 'ask', + source: 'watcher', + inboxTimestamp: '2026-05-08T10:00:00.000Z', + }) + ).resolves.toMatchObject({ + delivered: true, + accepted: true, + responsePending: true, + responseState: 'reconcile_failed', + }); + + expect(adapter.permissionListInputs).toEqual([ + { + teamName, + laneId: 'primary', + cwd: projectPath, + memberName: 'alice', + sessionId: 'session-alice', + }, + ]); + expect(adapter.observeInputs).toHaveLength(1); + expect(adapter.observeInputs[0]).toMatchObject({ + sessionId: 'session-alice', + runtimePromptMessageId: 'prompt-msg-pure-opencode-inline-observe-permission-blocked', + prePromptCursor: 'cursor-before-inline-observe-permission', + }); + const approval = approvalEvents.find( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approval).toMatchObject({ + requestId: `opencode:${launch.runId}:perm-alice-inline-observe`, + runId: launch.runId, + teamName, + providerId: 'opencode', + source: 'alice', + toolName: 'Bash', + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-alice-inline-observe', + sessionId: 'session-alice', + }, + }); + }); + + it('does not assign unknown primary-lane OpenCode permission sessions to the delivery target', async () => { + const teamName = 'pure-opencode-primary-permission-session-scope-safe-e2e'; + const adapter = new PermissionBlockedWithoutSessionOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-delivery', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + { + providerId: 'opencode', + requestId: 'perm-unknown-session', + sessionId: 'session-charlie', + tool: 'bash', + title: 'Run npm test', + kind: 'tool', + raw: { patterns: ['npm test'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + ], + }, + () => undefined + ); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger a permission-blocked delivery without session evidence', + messageId: 'msg-pure-opencode-permission-blocked-no-session', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + const approvals = approvalEvents.filter( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approvals.map((event) => event.runtimePermission?.providerRequestId)).toEqual([ + 'perm-alice-delivery', + ]); + expect(approvals[0]).toMatchObject({ + requestId: `opencode:${launch.runId}:perm-alice-delivery`, + source: 'alice', + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-alice-delivery', + sessionId: 'session-alice', + }, + }); + }); + + it('uses current OpenCode runtime session evidence when persisted launch state is unavailable', async () => { + const teamName = 'pure-opencode-primary-permission-runtime-session-map-safe-e2e'; + const adapter = new PermissionBlockedWithoutSessionOpenCodeRuntimeAdapter(); + adapter.setRuntimePermissions('primary', [ + { + providerId: 'opencode', + requestId: 'perm-alice-delivery', + sessionId: 'session-alice', + tool: 'bash', + title: 'Run git status', + kind: 'tool', + raw: { patterns: ['git status'] }, + }, + { + providerId: 'opencode', + requestId: 'perm-unknown-session', + sessionId: 'session-charlie', + tool: 'bash', + title: 'Run npm test', + kind: 'tool', + raw: { patterns: ['npm test'] }, + }, + ]); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const approvalEvents: ToolApprovalEvent[] = []; + svc.setToolApprovalEventEmitter((event) => approvalEvents.push(event)); + + const launch = await svc.createTeam( + { + teamName, + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + ], + }, + () => undefined + ); + await fs.rm(path.join(getTeamsBasePath(), teamName, 'launch-state.json'), { force: true }); + + await expect( + svc.deliverOpenCodeMemberMessage(teamName, { + memberName: 'alice', + text: 'trigger permission-blocked delivery after launch-state disappeared', + messageId: 'msg-pure-opencode-permission-runtime-session-map', + }) + ).resolves.toMatchObject({ + delivered: false, + responseState: 'permission_blocked', + }); + + expect(adapter.permissionListInputs).toEqual([ + { + teamName, + laneId: 'primary', + cwd: projectPath, + memberName: 'alice', + sessionId: undefined, + }, + ]); + const approvals = approvalEvents.filter( + (event): event is ToolApprovalRequest => + !('dismissed' in event) && !('autoResolved' in event) + ); + expect(approvals.map((event) => event.runtimePermission?.providerRequestId)).toEqual([ + 'perm-alice-delivery', + ]); + expect(approvals[0]).toMatchObject({ + requestId: `opencode:${launch.runId}:perm-alice-delivery`, + source: 'alice', + runtimePermission: { + providerId: 'opencode', + laneId: 'primary', + memberName: 'alice', + providerRequestId: 'perm-alice-delivery', + sessionId: 'session-alice', + }, + }); + }); + it('refreshes stale OpenCode session evidence before direct delivery when MCP transport changed', async () => { const teamName = 'pure-opencode-direct-message-transport-refresh-safe-e2e'; const adapter = new FakeOpenCodeRuntimeAdapter(); @@ -18403,8 +19173,16 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { readonly launchInputs: TeamRuntimeLaunchInput[] = []; readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = []; readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = []; + readonly permissionListInputs: Array<{ + teamName: string; + laneId: string; + cwd: string; + memberName?: string; + sessionId?: string | null; + }> = []; readonly reconcileInputs: TeamRuntimeReconcileInput[] = []; readonly stopInputs: TeamRuntimeStopInput[] = []; + private readonly runtimePermissionsByLane = new Map(); constructor( private launchState: TeamRuntimeLaunchResult['teamLaunchState'] = 'clean_success', @@ -18419,6 +19197,10 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { this.memberOutcomes = memberOutcomes; } + setRuntimePermissions(laneId: string, permissions: TeamRuntimePendingPermission[]): void { + this.runtimePermissionsByLane.set(laneId, permissions); + } + async prepare(input: TeamRuntimeLaunchInput): Promise { return { ok: true, @@ -18493,6 +19275,24 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + async listRuntimePermissions( + input: TeamRuntimePermissionListInput + ): Promise { + const laneId = input.laneId ?? 'primary'; + const cwd = input.cwd ?? ''; + this.permissionListInputs.push({ + teamName: input.teamName, + laneId, + cwd, + memberName: input.memberName, + sessionId: input.sessionId, + }); + return { + permissions: [...(this.runtimePermissionsByLane.get(laneId) ?? [])], + diagnostics: [], + }; + } + async reconcile(input: TeamRuntimeReconcileInput): Promise { this.reconcileInputs.push(input); const members = Object.fromEntries( @@ -18674,6 +19474,86 @@ class VisibleReplyOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { } } +class PermissionBlockedOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { + override async sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise { + this.messageInputs.push(input); + return { + ok: false, + providerId: 'opencode', + memberName: input.memberName, + sessionId: `session-${input.memberName}`, + responseObservation: { + state: 'permission_blocked', + deliveredUserMessageId: null, + assistantMessageId: null, + toolCallNames: [], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'OpenCode session has 1 pending permission request(s)', + }, + diagnostics: [ + 'OpenCode API error', + 'OpenCode session has 1 pending permission request(s)', + ], + }; + } +} + +class PermissionBlockedWithoutSessionOpenCodeRuntimeAdapter extends PermissionBlockedOpenCodeRuntimeAdapter { + override async sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise { + const result = await super.sendMessageToMember(input); + return { + ...result, + sessionId: undefined, + }; + } +} + +class PermissionBlockedInlineObserveOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { + readonly observeInputs: Array< + OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } + > = []; + + override async sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise { + this.messageInputs.push(input); + return { + ok: true, + providerId: 'opencode', + memberName: input.memberName, + sessionId: `session-${input.memberName}`, + runtimePromptMessageId: `prompt-${input.messageId ?? input.memberName}`, + prePromptCursor: 'cursor-before-inline-observe-permission', + responseObservation: { + state: 'tool_error', + deliveredUserMessageId: `delivered-${input.messageId ?? input.memberName}`, + assistantMessageId: `assistant-${input.messageId ?? input.memberName}`, + toolCallNames: ['agent-teams_message_send'], + visibleMessageToolCallId: `call-${input.messageId ?? input.memberName}`, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: 'message_send_tool_error_without_visible_reply_proof', + }, + diagnostics: ['OpenCode tool failed without output'], + }; + } + + async observeMessageDelivery( + input: OpenCodeTeamRuntimeMessageInput & { prePromptCursor?: string | null } + ): Promise { + this.observeInputs.push(input); + throw new Error('OpenCode session has 1 pending permission request(s)'); + } +} + class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = []; diff --git a/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts index a98a03f8..9d0d6f38 100644 --- a/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts +++ b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts @@ -89,6 +89,11 @@ describe('TeamProvisioningLaunchFailurePolicy', () => { 'Teammate did not join within the launch grace window.; process table unavailable' ) ).toBe(true); + expect( + isAutoClearableLaunchFailureReason( + 'CLI process exited (code 1) — team provisioned but not alive' + ) + ).toBe(true); expect(isAutoClearableLaunchFailureReason('model not found')).toBe(false); expect(isAutoClearableLaunchFailureReason(undefined)).toBe(false); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 35bb481a..26ab1a4d 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -26677,6 +26677,90 @@ describe('TeamProvisioningService', () => { }); }); + it('reconciles confirmed primary bootstrap after CLI provisioned-but-not-alive exit', async () => { + const teamName = 'primary-bootstrap-cli-provisioned-not-alive-heals'; + const bootstrapRunId = 'run-primary-cli-exit-after-bootstrap'; + const reason = 'CLI process exited (code 1) \u2014 team provisioned but not alive'; + writeTeamMeta(teamName, { + providerId: 'anthropic', + model: 'sonnet', + }); + writeMembersMeta(teamName, [{ name: 'tom', providerId: 'anthropic', model: 'sonnet' }]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['tom']); + writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId); + writeBootstrapState( + teamName, + [ + { + name: 'tom', + status: 'bootstrap_confirmed', + lastAttemptAt: Date.parse('2026-05-25T20:13:46.326Z'), + lastObservedAt: Date.parse('2026-05-25T20:13:56.110Z'), + }, + ], + '2026-05-25T20:14:03.317Z', + { runId: bootstrapRunId } + ); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['tom'], + members: { + tom: { + name: 'tom', + providerId: 'anthropic', + model: 'sonnet', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'anthropic', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + runtimePid: 27_036, + bootstrapConfirmed: true, + hardFailure: true, + hardFailureReason: reason, + livenessKind: 'confirmed_bootstrap', + pidSource: 'persisted_metadata', + runtimeDiagnostic: + 'runtime pid could not be verified because process table is unavailable', + runtimeDiagnosticSeverity: 'warning', + firstSpawnAcceptedAt: '2026-05-25T20:13:46.326Z', + lastHeartbeatAt: '2026-05-25T20:13:56.110Z', + runtimeLastSeenAt: '2026-05-25T20:13:46.326Z', + lastEvaluatedAt: '2026-05-25T20:14:05.411Z', + }, + }, + updatedAt: '2026-05-25T20:14:05.411Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('clean_success'); + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + runtimeAlive: false, + livenessKind: 'confirmed_bootstrap', + hardFailure: false, + error: undefined, + }); + expect(result.statuses.tom?.hardFailureReason).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined(); + expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined(); + }); + it('cleans stale confirmed primary diagnostics from an already successful mixed launch', async () => { const teamName = 'mixed-confirmed-primary-stale-diagnostic-cleans'; writeTeamMeta(teamName, {