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 { import {
deriveMemberLaunchState, deriveMemberLaunchState,
isAutoClearableLaunchFailureReason, isAutoClearableLaunchFailureReason,
isCliProvisionedButNotAliveFailureReason,
isNeverSpawnedDuringLaunchReason, isNeverSpawnedDuringLaunchReason,
} from './provisioning/TeamProvisioningLaunchFailurePolicy'; } from './provisioning/TeamProvisioningLaunchFailurePolicy';
import { import {
@ -523,6 +524,8 @@ import type {
TeamRuntimeLaunchResult, TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence, TeamRuntimeMemberLaunchEvidence,
TeamRuntimeMemberSpec, TeamRuntimeMemberSpec,
TeamRuntimePendingPermission,
TeamRuntimePermissionListResult,
TeamRuntimePrepareResult, TeamRuntimePrepareResult,
TeamRuntimeStopInput, TeamRuntimeStopInput,
} from './runtime'; } from './runtime';
@ -540,6 +543,16 @@ type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & {
): Promise<OpenCodeTeamRuntimeMessageResult>; ): 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). * 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.'; 'OpenCode runtime binary is not installed or not reachable by launch preflight.';
const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC = const OPENCODE_APP_MCP_UNREACHABLE_DIAGNOSTIC =
'OpenCode app MCP is unreachable. Retry launch to refresh the app MCP bridge.'; '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 { function pushUniqueLine(lines: string[], line: string): void {
const trimmed = line.trim(); const trimmed = line.trim();
@ -5031,6 +5046,14 @@ export class TeamProvisioningService {
return adapter as OpenCodeRuntimeMessageAdapter; 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( private resolveRuntimeRecipientProviderIdFromSources(
memberName: string, memberName: string,
config: TeamConfig | null | undefined, config: TeamConfig | null | undefined,
@ -6261,6 +6284,19 @@ export class TeamProvisioningService {
const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage( const reason = `opencode_direct_user_delivery_inline_observe_failed: ${getErrorMessage(
error 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({ ledgerRecord = await input.ledger.applyObservation({
id: ledgerRecord.id, id: ledgerRecord.id,
responseObservation: { responseObservation: {
@ -6294,6 +6330,17 @@ export class TeamProvisioningService {
const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation( const observedResponse = this.normalizeOpenCodeDeliveryResponseObservation(
observed.responseObservation 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); const hadMessageSendToolError = this.hasOpenCodeObservedMessageSendToolCall(ledgerRecord);
ledgerRecord = await input.ledger.applyObservation({ ledgerRecord = await input.ledger.applyObservation({
id: ledgerRecord.id, id: ledgerRecord.id,
@ -6773,15 +6820,16 @@ export class TeamProvisioningService {
try { try {
const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => { const changed = await this.enqueueLaunchStateStoreOperation(input.teamName, async () => {
const previous = await this.launchStateStore.read(input.teamName).catch(() => null); const previous = await this.launchStateStore.read(input.teamName).catch(() => null);
const directMember = previous?.members[input.memberName]; const previousEntry = this.findPersistedLaunchMemberForLane({
const laneMemberEntry = Object.entries(previous?.members ?? {}).find( previousLaunchState: previous,
([, member]) => member.laneId === input.laneId laneId: input.laneId,
); memberName: input.memberName,
const previousMember = directMember ?? laneMemberEntry?.[1]; runId: input.runId,
const previousMemberKey = directMember ? input.memberName : laneMemberEntry?.[0]; });
if (!previous || !previousMember) { if (!previous || !previousEntry) {
return false; return false;
} }
const previousMember = previousEntry.member;
if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) { if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) {
return false; return false;
} }
@ -6831,7 +6879,7 @@ export class TeamProvisioningService {
launchPhase: previous.launchPhase, launchPhase: previous.launchPhase,
members: { members: {
...previous.members, ...previous.members,
[previousMemberKey ?? previousMember.name]: nextMember, [previousEntry.key]: nextMember,
}, },
updatedAt: observedAt, 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( private logOpenCodePromptDeliveryEvent(
event: string, event: string,
record: OpenCodePromptDeliveryLedgerRecord, record: OpenCodePromptDeliveryLedgerRecord,
@ -7678,6 +8337,19 @@ export class TeamProvisioningService {
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
result.responseObservation 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 { return {
delivered: result.ok, delivered: result.ok,
accepted: result.ok, accepted: result.ok,
@ -7950,6 +8622,19 @@ export class TeamProvisioningService {
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
observed.responseObservation 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({ ledgerRecord = await ledger.applyObservation({
id: ledgerRecord.id, id: ledgerRecord.id,
responseObservation: responseObservation ?? { responseObservation: responseObservation ?? {
@ -8176,6 +8861,17 @@ export class TeamProvisioningService {
}); });
} catch (error) { } catch (error) {
const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(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) { if (ledgerRecord && ledger) {
ledgerRecord = await ledger.applyDeliveryResult({ ledgerRecord = await ledger.applyDeliveryResult({
id: ledgerRecord.id, id: ledgerRecord.id,
@ -8256,6 +8952,19 @@ export class TeamProvisioningService {
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation( const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
result.responseObservation 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( const promptAcceptedByRuntimeIdentity = Boolean(
result.ok && result.runtimePromptMessageId?.trim() result.ok && result.runtimePromptMessageId?.trim()
); );
@ -28752,6 +29461,8 @@ export class TeamProvisioningService {
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic; const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason); const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason);
const requiresConfirmedBootstrapToClearFailure =
isCliProvisionedButNotAliveFailureReason(initialFailureReason);
current.runtimeAlive = observedRuntimeAlive; current.runtimeAlive = observedRuntimeAlive;
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
current.livenessKind = runtimeMetadata?.[1].livenessKind; current.livenessKind = runtimeMetadata?.[1].livenessKind;
@ -28775,6 +29486,7 @@ export class TeamProvisioningService {
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
if ( if (
hadAutoClearableFailure && hadAutoClearableFailure &&
!requiresConfirmedBootstrapToClearFailure &&
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
) { ) {
current.hardFailure = false; current.hardFailure = false;
@ -31445,6 +32157,7 @@ export class TeamProvisioningService {
cwd: string; cwd: string;
members: Record<string, TeamRuntimeMemberLaunchEvidence>; members: Record<string, TeamRuntimeMemberLaunchEvidence>;
expectedMembers: TeamRuntimeMemberSpec[]; expectedMembers: TeamRuntimeMemberSpec[];
memberNames?: readonly string[];
teamColor?: string; teamColor?: string;
teamDisplayName?: string; teamDisplayName?: string;
}): void { }): void {
@ -31454,6 +32167,7 @@ export class TeamProvisioningService {
teamName: input.teamName, teamName: input.teamName,
runId: input.runId, runId: input.runId,
laneId: input.laneId, laneId: input.laneId,
memberNames: input.memberNames,
providerId: 'opencode', providerId: 'opencode',
}, },
entries entries

View file

@ -64,6 +64,7 @@ export interface RuntimeToolApprovalSyncScope {
teamName: string; teamName: string;
runId: string; runId: string;
laneId?: string; laneId?: string;
memberNames?: readonly string[];
providerId?: RuntimeApprovalProviderId; providerId?: RuntimeApprovalProviderId;
} }
@ -405,6 +406,9 @@ export class RuntimeToolApprovalCoordinator {
if (scope.laneId && entry.laneId !== scope.laneId) { if (scope.laneId && entry.laneId !== scope.laneId) {
return false; return false;
} }
if (scope.memberNames?.length && !scope.memberNames.includes(entry.memberName)) {
return false;
}
if (scope.providerId && entry.providerId !== scope.providerId) { if (scope.providerId && entry.providerId !== scope.providerId) {
return false; return false;
} }

View file

@ -160,11 +160,14 @@ export interface OpenCodeListRuntimePermissionsCommandBody {
teamId: string; teamId: string;
teamName: string; teamName: string;
laneId?: string; laneId?: string;
memberName?: string;
sessionId?: string | null;
projectPath?: string; projectPath?: string;
} }
export interface OpenCodeListRuntimePermissionsCommandData { export interface OpenCodeListRuntimePermissionsCommandData {
permissions: OpenCodeRuntimePermissionCommandData[]; permissions: OpenCodeRuntimePermissionCommandData[];
diagnostics?: string[];
} }
export interface OpenCodeCleanupHostsCommandBody { export interface OpenCodeCleanupHostsCommandBody {

View file

@ -257,7 +257,13 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
if (result.ok) { if (result.ok) {
return result.data; 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( 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 { export function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null {
const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim()); const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim());
const baseReason = match?.[1]?.trim(); const baseReason = match?.[1]?.trim();
@ -53,7 +63,8 @@ function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean {
isBootstrapMcpResourceReadFailureReason(reason) || isBootstrapMcpResourceReadFailureReason(reason) ||
isBootstrapCheckInTimeoutFailureReason(reason) || isBootstrapCheckInTimeoutFailureReason(reason) ||
isBootstrapInstructionPromptFailureReason(reason) || isBootstrapInstructionPromptFailureReason(reason) ||
isLaunchCleanupBootstrapIncompleteFailureReason(reason) isLaunchCleanupBootstrapIncompleteFailureReason(reason) ||
isCliProvisionedButNotAliveFailureReason(reason)
); );
} }

View file

@ -5,6 +5,8 @@ import type {
OpenCodeBridgeRuntimeSnapshot, OpenCodeBridgeRuntimeSnapshot,
OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData, OpenCodeLaunchTeamCommandData,
OpenCodeListRuntimePermissionsCommandBody,
OpenCodeListRuntimePermissionsCommandData,
OpenCodeObserveMessageDeliveryCommandBody, OpenCodeObserveMessageDeliveryCommandBody,
OpenCodeObserveMessageDeliveryCommandData, OpenCodeObserveMessageDeliveryCommandData,
OpenCodeReconcileTeamCommandBody, OpenCodeReconcileTeamCommandBody,
@ -24,6 +26,8 @@ import type {
TeamRuntimeMemberStopEvidence, TeamRuntimeMemberStopEvidence,
TeamRuntimePendingPermission, TeamRuntimePendingPermission,
TeamRuntimePermissionAnswerInput, TeamRuntimePermissionAnswerInput,
TeamRuntimePermissionListInput,
TeamRuntimePermissionListResult,
TeamRuntimePrepareResult, TeamRuntimePrepareResult,
TeamRuntimeReconcileInput, TeamRuntimeReconcileInput,
TeamRuntimeReconcileResult, TeamRuntimeReconcileResult,
@ -59,6 +63,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
answerOpenCodeRuntimePermission?( answerOpenCodeRuntimePermission?(
input: OpenCodeAnswerPermissionCommandBody input: OpenCodeAnswerPermissionCommandBody
): Promise<OpenCodeLaunchTeamCommandData>; ): Promise<OpenCodeLaunchTeamCommandData>;
listOpenCodeRuntimePermissions?(
input: OpenCodeListRuntimePermissionsCommandBody
): Promise<OpenCodeListRuntimePermissionsCommandData>;
} }
export interface OpenCodeTeamRuntimeMessageInput { 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> { async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
if (this.bridge.stopOpenCodeTeam) { if (this.bridge.stopOpenCodeTeam) {
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);

View file

@ -56,6 +56,19 @@ export interface TeamRuntimePermissionAnswerInput {
previousLaunchState: PersistedTeamLaunchSnapshot | null; 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 { export interface TeamRuntimeLaunchInput {
runId: string; runId: string;
teamName: string; teamName: string;
@ -206,6 +219,9 @@ export interface TeamLaunchRuntimeAdapter {
answerRuntimePermission?( answerRuntimePermission?(
input: TeamRuntimePermissionAnswerInput input: TeamRuntimePermissionAnswerInput
): Promise<TeamRuntimeLaunchResult>; ): Promise<TeamRuntimeLaunchResult>;
listRuntimePermissions?(
input: TeamRuntimePermissionListInput
): Promise<TeamRuntimePermissionListResult>;
} }
export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId { export function isTeamRuntimeProviderId(value: unknown): value is TeamRuntimeProviderId {

View file

@ -14,6 +14,8 @@ export type {
TeamRuntimeMemberStopEvidence, TeamRuntimeMemberStopEvidence,
TeamRuntimePendingApproval, TeamRuntimePendingApproval,
TeamRuntimePendingPermission, TeamRuntimePendingPermission,
TeamRuntimePermissionListInput,
TeamRuntimePermissionListResult,
TeamRuntimePrepareFailure, TeamRuntimePrepareFailure,
TeamRuntimePrepareResult, TeamRuntimePrepareResult,
TeamRuntimePrepareSuccess, 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 () => { it('gives observeMessageDelivery enough time for OpenCode plain-text fallback reconciliation', async () => {
const executor = fakeExecutor( const executor = fakeExecutor(
bridgeCommandSuccess({ bridgeCommandSuccess({

View file

@ -1586,6 +1586,91 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
).rejects.toThrow('OpenCode permission answer bridge is not registered.'); ).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 () => { it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
const launchOpenCodeTeam = vi.fn( const launchOpenCodeTeam = vi.fn(
async () => 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 () => { it('rejects stale UI responses by run id', async () => {
coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]); coordinator.sync({ teamName: 'team-a', runId: 'run-1', laneId: 'primary' }, [approvalEntry()]);

View file

@ -26,7 +26,10 @@ import {
type TeamRuntimeLaunchResult, type TeamRuntimeLaunchResult,
type TeamRuntimeMemberLaunchEvidence, type TeamRuntimeMemberLaunchEvidence,
type TeamRuntimeMemberSpec, type TeamRuntimeMemberSpec,
type TeamRuntimePendingPermission,
type TeamRuntimePermissionAnswerInput, type TeamRuntimePermissionAnswerInput,
type TeamRuntimePermissionListInput,
type TeamRuntimePermissionListResult,
type TeamRuntimePrepareResult, type TeamRuntimePrepareResult,
type TeamRuntimeReconcileInput, type TeamRuntimeReconcileInput,
type TeamRuntimeReconcileResult, type TeamRuntimeReconcileResult,
@ -10368,6 +10371,222 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs[0]?.runId).not.toBe(staleRun.runId); 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 () => { 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'; const teamName = 'mixed-opencode-secondary-transport-refresh-safe-e2e';
await writeMixedTeamConfig({ await writeMixedTeamConfig({
@ -10863,6 +11082,557 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId); 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 () => { 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 teamName = 'pure-opencode-direct-message-transport-refresh-safe-e2e';
const adapter = new FakeOpenCodeRuntimeAdapter(); const adapter = new FakeOpenCodeRuntimeAdapter();
@ -18403,8 +19173,16 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
readonly launchInputs: TeamRuntimeLaunchInput[] = []; readonly launchInputs: TeamRuntimeLaunchInput[] = [];
readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = []; readonly messageInputs: OpenCodeTeamRuntimeMessageInput[] = [];
readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = []; readonly permissionAnswerInputs: TeamRuntimePermissionAnswerInput[] = [];
readonly permissionListInputs: Array<{
teamName: string;
laneId: string;
cwd: string;
memberName?: string;
sessionId?: string | null;
}> = [];
readonly reconcileInputs: TeamRuntimeReconcileInput[] = []; readonly reconcileInputs: TeamRuntimeReconcileInput[] = [];
readonly stopInputs: TeamRuntimeStopInput[] = []; readonly stopInputs: TeamRuntimeStopInput[] = [];
private readonly runtimePermissionsByLane = new Map<string, TeamRuntimePendingPermission[]>();
constructor( constructor(
private launchState: TeamRuntimeLaunchResult['teamLaunchState'] = 'clean_success', private launchState: TeamRuntimeLaunchResult['teamLaunchState'] = 'clean_success',
@ -18419,6 +19197,10 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
this.memberOutcomes = memberOutcomes; this.memberOutcomes = memberOutcomes;
} }
setRuntimePermissions(laneId: string, permissions: TeamRuntimePendingPermission[]): void {
this.runtimePermissionsByLane.set(laneId, permissions);
}
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> { async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
return { return {
ok: true, 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> { async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
this.reconcileInputs.push(input); this.reconcileInputs.push(input);
const members = Object.fromEntries( 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 { class BootstrapCheckingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
readonly bootstrapCheckins: { memberName: string; runId: string; state: string }[] = []; 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' 'Teammate did not join within the launch grace window.; process table unavailable'
) )
).toBe(true); ).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('model not found')).toBe(false);
expect(isAutoClearableLaunchFailureReason(undefined)).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 () => { it('cleans stale confirmed primary diagnostics from an already successful mixed launch', async () => {
const teamName = 'mixed-confirmed-primary-stale-diagnostic-cleans'; const teamName = 'mixed-confirmed-primary-stale-diagnostic-cleans';
writeTeamMeta(teamName, { writeTeamMeta(teamName, {