fix(agent-teams): surface OpenCode runtime permissions

This commit is contained in:
777genius 2026-05-26 19:46:24 +03:00
parent 636beb5e42
commit f237318c29
14 changed files with 1933 additions and 10 deletions

View file

@ -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<OpenCodeTeamRuntimeMessageResult>;
};
type OpenCodeRuntimePermissionListingAdapter = TeamLaunchRuntimeAdapter & {
listRuntimePermissions(input: {
teamName: string;
laneId: string;
cwd: string;
memberName?: string;
sessionId?: string | null;
}): Promise<TeamRuntimePermissionListResult>;
};
/**
* 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<void> {
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<string, TeamRuntimeMemberLaunchEvidence> = {};
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<string, TeamRuntimeMemberSpec>();
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<string, TeamRuntimePendingPermission[]> {
const sessionToMember = new Map<string, string>();
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<string, TeamRuntimePendingPermission[]>();
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<string, readonly TeamRuntimePendingPermission[]>;
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}): Promise<void> {
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<string, readonly TeamRuntimePendingPermission[]>;
}): 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<string, TeamRuntimeMemberLaunchEvidence>;
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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<OpenCodeLaunchTeamCommandData>;
listOpenCodeRuntimePermissions?(
input: OpenCodeListRuntimePermissionsCommandBody
): Promise<OpenCodeListRuntimePermissionsCommandData>;
}
export interface OpenCodeTeamRuntimeMessageInput {
@ -599,6 +606,30 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
);
}
async listRuntimePermissions(
input: TeamRuntimePermissionListInput
): Promise<TeamRuntimePermissionListResult> {
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<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);

View file

@ -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<TeamRuntimeLaunchResult>;
listRuntimePermissions?(
input: TeamRuntimePermissionListInput
): Promise<TeamRuntimePermissionListResult>;
}
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {

View file

@ -14,6 +14,8 @@ export type {
TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval,
TeamRuntimePendingPermission,
TeamRuntimePermissionListInput,
TeamRuntimePermissionListResult,
TeamRuntimePrepareFailure,
TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess,

View file

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

View file

@ -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<OpenCodeTeamRuntimeBridgePort['listOpenCodeRuntimePermissions']>
>(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 () =>

View file

@ -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()]);

View file

@ -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<string, { pendingPermissionRequestIds?: string[] }> };
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<string, { pendingPermissionRequestIds?: string[] }>;
};
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<void>((resolve) => {
markListStarted = resolve;
});
const releaseListPromise = new Promise<void>((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<unknown>;
};
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<string, TeamRuntimePendingPermission[]>();
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<TeamRuntimePrepareResult> {
return {
ok: true,
@ -18493,6 +19275,24 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
};
}
async listRuntimePermissions(
input: TeamRuntimePermissionListInput
): Promise<TeamRuntimePermissionListResult> {
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<TeamRuntimeReconcileResult> {
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<OpenCodeTeamRuntimeMessageResult> {
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<OpenCodeTeamRuntimeMessageResult> {
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<OpenCodeTeamRuntimeMessageResult> {
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<OpenCodeTeamRuntimeMessageResult> {
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 }[] = [];

View file

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

View file

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