fix(agent-teams): surface OpenCode runtime permissions
This commit is contained in:
parent
636beb5e42
commit
f237318c29
14 changed files with 1933 additions and 10 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export type {
|
||||||
TeamRuntimeMemberStopEvidence,
|
TeamRuntimeMemberStopEvidence,
|
||||||
TeamRuntimePendingApproval,
|
TeamRuntimePendingApproval,
|
||||||
TeamRuntimePendingPermission,
|
TeamRuntimePendingPermission,
|
||||||
|
TeamRuntimePermissionListInput,
|
||||||
|
TeamRuntimePermissionListResult,
|
||||||
TeamRuntimePrepareFailure,
|
TeamRuntimePrepareFailure,
|
||||||
TeamRuntimePrepareResult,
|
TeamRuntimePrepareResult,
|
||||||
TeamRuntimePrepareSuccess,
|
TeamRuntimePrepareSuccess,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 () =>
|
||||||
|
|
|
||||||
|
|
@ -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()]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }[] = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue