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