fix: clear stale team launch reconciliation
This commit is contained in:
parent
d495f8d8f2
commit
1a872b592a
10 changed files with 1073 additions and 14 deletions
|
|
@ -94,6 +94,7 @@ function buildDiagnostics(
|
|||
| 'agentToolAccepted'
|
||||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'hardFailureReason'
|
||||
| 'sources'
|
||||
| 'pendingPermissionRequestIds'
|
||||
|
|
@ -108,6 +109,8 @@ function buildDiagnostics(
|
|||
diagnostics.push('waiting for permission approval');
|
||||
} else if (member.bootstrapStalled) {
|
||||
diagnostics.push('opencode_bootstrap_stalled');
|
||||
} else if (member.hardFailure && member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('bootstrap failed while runtime process was still alive');
|
||||
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('waiting for teammate check-in');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2113,6 +2113,8 @@ app.on('before-quit', (event) => {
|
|||
|
||||
event.preventDefault();
|
||||
|
||||
notificationManager.closeActiveNativeNotifications('app-before-quit');
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.hide();
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,17 @@ export class NotificationManager extends EventEmitter {
|
|||
* Shared click handler for native notifications — focuses window and emits deep-link.
|
||||
*/
|
||||
private handleNativeNotificationClick(stored: StoredNotification): void {
|
||||
const isDevRuntime =
|
||||
process.env.NODE_ENV !== 'production' ||
|
||||
Boolean((process as typeof process & { defaultApp?: boolean }).defaultApp);
|
||||
if (isDevRuntime) {
|
||||
const notificationType = stored.teamEventType ?? stored.category ?? 'error';
|
||||
const notificationTitle = stored.triggerName ?? stored.message;
|
||||
logger.info(
|
||||
`[notification-click] delivered in-process id=${stored.id} type=${notificationType} title="${notificationTitle}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
|
|
@ -1119,6 +1130,30 @@ export class NotificationManager extends EventEmitter {
|
|||
this.emit('notification-clicked', stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes active OS notifications so macOS does not keep stale dev toasts that
|
||||
* can relaunch raw Electron without the app path.
|
||||
*/
|
||||
closeActiveNativeNotifications(reason: string = 'manual'): number {
|
||||
const notifications = Array.from(this.activeNotifications);
|
||||
for (const notification of notifications) {
|
||||
try {
|
||||
(notification as NotificationInstance & { close?: () => void }).close?.();
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[notification] failed to close active notification during ${reason}: ${String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.activeNotifications.clear();
|
||||
if (notifications.length > 0) {
|
||||
logger.debug(
|
||||
`[notification] closed ${notifications.length} active notification(s): ${reason}`
|
||||
);
|
||||
}
|
||||
return notifications.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: checks if Electron's Notification API is available.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
|
||||
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
|
||||
const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||
|
||||
function isCodexExecBinary(binaryPath?: string | null): boolean {
|
||||
|
|
@ -106,6 +107,18 @@ function applyCodexRuntimeContextEnv(
|
|||
}
|
||||
}
|
||||
|
||||
function applyCodexForcedLoginMethodEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
loginMethod: 'chatgpt' | 'api' | null
|
||||
): void {
|
||||
if (loginMethod) {
|
||||
env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR] = loginMethod;
|
||||
return;
|
||||
}
|
||||
|
||||
delete env[CODEX_FORCED_LOGIN_METHOD_ENV_VAR];
|
||||
}
|
||||
|
||||
export class ProviderConnectionService {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||
|
|
@ -213,6 +226,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'chatgpt') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, 'chatgpt');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +234,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
|
||||
env.OPENAI_API_KEY = resolvedApiKey;
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
applyCodexForcedLoginMethodEnv(env, 'api');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -227,6 +242,7 @@ export class ProviderConnectionService {
|
|||
delete env.OPENAI_API_KEY;
|
||||
}
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, null);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -274,6 +290,7 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'chatgpt') {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
applyCodexForcedLoginMethodEnv(env, 'chatgpt');
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
@ -281,8 +298,11 @@ export class ProviderConnectionService {
|
|||
if (readiness.effectiveAuthMode === 'api_key' && resolvedApiKey) {
|
||||
env.OPENAI_API_KEY = resolvedApiKey;
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
applyCodexForcedLoginMethodEnv(env, 'api');
|
||||
return env;
|
||||
}
|
||||
|
||||
applyCodexForcedLoginMethodEnv(env, null);
|
||||
return env;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ function buildDiagnostics(
|
|||
| 'agentToolAccepted'
|
||||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'hardFailureReason'
|
||||
| 'skippedForLaunch'
|
||||
| 'skipReason'
|
||||
|
|
@ -251,6 +252,8 @@ function buildDiagnostics(
|
|||
diagnostics.push('waiting for permission approval');
|
||||
} else if (member.bootstrapStalled) {
|
||||
diagnostics.push('opencode_bootstrap_stalled');
|
||||
} else if (member.hardFailure && member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('bootstrap failed while runtime process was still alive');
|
||||
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||
diagnostics.push('waiting for teammate check-in');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { hasMixedPersistedLaunchMetadata } from './TeamLaunchStateEvaluator';
|
|||
import type { PersistedTeamLaunchSnapshot, TeamProviderId, TeamSummary } from '@shared/types';
|
||||
|
||||
export const TEAM_LAUNCH_SUMMARY_FILE = 'launch-summary.json';
|
||||
const STALE_PENDING_SUMMARY_GRACE_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface LaunchStateSummary {
|
||||
partialLaunchFailure?: true;
|
||||
|
|
@ -32,6 +33,7 @@ export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary
|
|||
version: 1;
|
||||
teamName: string;
|
||||
updatedAt: string;
|
||||
launchPhase?: PersistedTeamLaunchSnapshot['launchPhase'];
|
||||
mixedAware?: true;
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +103,7 @@ export function createPersistedLaunchSummaryProjection(
|
|||
version: 1,
|
||||
teamName: snapshot.teamName,
|
||||
updatedAt: snapshot.updatedAt,
|
||||
launchPhase: snapshot.launchPhase,
|
||||
...(hasMixedPersistedLaunchMetadata(snapshot) ? { mixedAware: true as const } : {}),
|
||||
...createLaunchStateSummary(snapshot),
|
||||
};
|
||||
|
|
@ -128,6 +131,13 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
updatedAt,
|
||||
...(record.mixedAware === true ? { mixedAware: true as const } : {}),
|
||||
};
|
||||
if (
|
||||
record.launchPhase === 'active' ||
|
||||
record.launchPhase === 'finished' ||
|
||||
record.launchPhase === 'reconciled'
|
||||
) {
|
||||
normalized.launchPhase = record.launchPhase;
|
||||
}
|
||||
|
||||
if (record.partialLaunchFailure === true) {
|
||||
normalized.partialLaunchFailure = true;
|
||||
|
|
@ -202,17 +212,55 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function shouldIgnoreStalePendingSummaryProjection(
|
||||
projection: PersistedTeamLaunchSummaryProjection,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
if (projection.teamLaunchState !== 'partial_pending') {
|
||||
return false;
|
||||
}
|
||||
if ((projection.permissionPendingCount ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedAtMs = toMillis(projection.launchUpdatedAt ?? projection.updatedAt);
|
||||
return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs >= STALE_PENDING_SUMMARY_GRACE_MS;
|
||||
}
|
||||
|
||||
function shouldIgnoreStalePendingLaunchSnapshotSummary(
|
||||
snapshot: PersistedTeamLaunchSnapshot,
|
||||
nowMs: number = Date.now()
|
||||
): boolean {
|
||||
if (snapshot.teamLaunchState !== 'partial_pending') {
|
||||
return false;
|
||||
}
|
||||
if ((snapshot.summary.permissionPendingCount ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedAtMs = toMillis(snapshot.updatedAt);
|
||||
return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs >= STALE_PENDING_SUMMARY_GRACE_MS;
|
||||
}
|
||||
|
||||
export function choosePreferredLaunchStateSummary(params: {
|
||||
bootstrapSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null;
|
||||
launchSummaryProjection?: PersistedTeamLaunchSummaryProjection | null;
|
||||
}): LaunchStateSummary | null {
|
||||
if (params.launchSnapshot) {
|
||||
return createLaunchStateSummary(params.launchSnapshot);
|
||||
const launchSnapshot =
|
||||
params.launchSnapshot && shouldIgnoreStalePendingLaunchSnapshotSummary(params.launchSnapshot)
|
||||
? null
|
||||
: (params.launchSnapshot ?? null);
|
||||
if (launchSnapshot) {
|
||||
return createLaunchStateSummary(launchSnapshot);
|
||||
}
|
||||
|
||||
const bootstrapSnapshot = params.bootstrapSnapshot ?? null;
|
||||
const projection = params.launchSummaryProjection ?? null;
|
||||
const projection =
|
||||
params.launchSummaryProjection &&
|
||||
shouldIgnoreStalePendingSummaryProjection(params.launchSummaryProjection)
|
||||
? null
|
||||
: (params.launchSummaryProjection ?? null);
|
||||
if (!bootstrapSnapshot) {
|
||||
return projection;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2753,6 +2753,20 @@ function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefi
|
|||
);
|
||||
}
|
||||
|
||||
function teamRequestIncludesCodexMember(
|
||||
request: Pick<TeamCreateRequest, 'providerId'> & Partial<Pick<TeamCreateRequest, 'members'>>
|
||||
): boolean {
|
||||
const defaultProviderId = normalizeTeamMemberProviderId(request.providerId) ?? 'anthropic';
|
||||
const members = Array.isArray(request.members) ? request.members : [];
|
||||
return members.some((member) => {
|
||||
const memberProviderId =
|
||||
normalizeTeamMemberProviderId(member.providerId) ??
|
||||
normalizeTeamMemberProviderId((member as { provider?: unknown }).provider) ??
|
||||
defaultProviderId;
|
||||
return memberProviderId === 'codex';
|
||||
});
|
||||
}
|
||||
|
||||
function buildEffectiveTeamMemberSpec(
|
||||
member: TeamMemberInput,
|
||||
defaults: {
|
||||
|
|
@ -6469,6 +6483,113 @@ export class TeamProvisioningService {
|
|||
return ledgerRecord;
|
||||
}
|
||||
|
||||
private async rememberOpenCodeRuntimePidFromBridge(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
laneId: string;
|
||||
runId?: string | null;
|
||||
runtimeSessionId?: string | null;
|
||||
runtimePid?: number;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
const runtimePid = normalizeRuntimePositiveInteger(input.runtimePid);
|
||||
if (!runtimePid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const command = this.readProcessCommandByPid(runtimePid);
|
||||
if (!command || !this.isOpenCodeServeCommand(command)) {
|
||||
logger.debug(
|
||||
`[${input.teamName}] Ignoring OpenCode bridge runtime pid ${runtimePid} for ${input.memberName}: process identity is not an active opencode serve host.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const observedAt = nowIso();
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
if (!isPersistedOpenCodeSecondaryLaneMember(previousMember)) {
|
||||
return false;
|
||||
}
|
||||
if (previousMember.laneId && previousMember.laneId !== input.laneId) {
|
||||
return false;
|
||||
}
|
||||
const previousRunId = previousMember.runtimeRunId?.trim();
|
||||
const incomingRunId = input.runId?.trim();
|
||||
if (previousRunId && incomingRunId && previousRunId !== incomingRunId) {
|
||||
return false;
|
||||
}
|
||||
const previousSessionId = previousMember.runtimeSessionId?.trim();
|
||||
const incomingSessionId = input.runtimeSessionId?.trim();
|
||||
if (previousSessionId && incomingSessionId && previousSessionId !== incomingSessionId) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
previousMember.runtimePid === runtimePid &&
|
||||
previousMember.pidSource === 'opencode_bridge'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextMember: PersistedTeamLaunchMemberState = {
|
||||
...previousMember,
|
||||
runtimePid,
|
||||
...(incomingRunId ? { runtimeRunId: incomingRunId } : {}),
|
||||
...(incomingSessionId ? { runtimeSessionId: incomingSessionId } : {}),
|
||||
pidSource: 'opencode_bridge',
|
||||
lastRuntimeAliveAt: observedAt,
|
||||
lastEvaluatedAt: observedAt,
|
||||
sources: {
|
||||
...(previousMember.sources ?? {}),
|
||||
processAlive: true,
|
||||
},
|
||||
diagnostics: mergeRuntimeDiagnostics(
|
||||
previousMember.diagnostics,
|
||||
[`runtime pid: ${runtimePid}`, input.reason],
|
||||
previousMember.runtimeDiagnostic
|
||||
),
|
||||
};
|
||||
const nextSnapshot = createPersistedLaunchSnapshot({
|
||||
teamName: previous.teamName,
|
||||
expectedMembers: previous.expectedMembers,
|
||||
bootstrapExpectedMembers: previous.bootstrapExpectedMembers,
|
||||
leadSessionId: previous.leadSessionId,
|
||||
launchPhase: previous.launchPhase,
|
||||
members: {
|
||||
...previous.members,
|
||||
[previousMemberKey ?? previousMember.name]: nextMember,
|
||||
},
|
||||
updatedAt: observedAt,
|
||||
});
|
||||
await this.writeLaunchStateSnapshotNow(input.teamName, nextSnapshot);
|
||||
return true;
|
||||
});
|
||||
if (changed) {
|
||||
this.invalidateRuntimeSnapshotCaches(input.teamName);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'member-spawn',
|
||||
teamName: input.teamName,
|
||||
...(input.runId ? { runId: input.runId } : {}),
|
||||
detail: input.memberName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[${input.teamName}] Failed to persist OpenCode bridge runtime pid ${runtimePid} for ${input.memberName}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private logOpenCodePromptDeliveryEvent(
|
||||
event: string,
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
|
|
@ -6847,6 +6968,15 @@ export class TeamProvisioningService {
|
|||
actionMode: input.actionMode,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
runId: runtimeRunId,
|
||||
runtimeSessionId: result.sessionId,
|
||||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
return {
|
||||
delivered: result.ok,
|
||||
accepted: result.ok,
|
||||
|
|
@ -7063,6 +7193,15 @@ export class TeamProvisioningService {
|
|||
taskRefs: input.taskRefs,
|
||||
prePromptCursor: ledgerRecord.prePromptCursor,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
runId: runtimeRunId,
|
||||
runtimeSessionId: observed.sessionId,
|
||||
runtimePid: observed.runtimePid,
|
||||
reason: 'opencode_delivery_observe_runtime_pid_observed',
|
||||
});
|
||||
ledgerRecord = await ledger.applyObservation({
|
||||
id: ledgerRecord.id,
|
||||
responseObservation: observed.responseObservation ?? {
|
||||
|
|
@ -7170,6 +7309,15 @@ export class TeamProvisioningService {
|
|||
actionMode: input.actionMode,
|
||||
taskRefs: input.taskRefs,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
laneId: laneIdentity.laneId,
|
||||
runId: runtimeRunId,
|
||||
runtimeSessionId: result.sessionId,
|
||||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
if (ledgerRecord && ledger) {
|
||||
ledgerRecord = await ledger.applyDeliveryResult({
|
||||
id: ledgerRecord.id,
|
||||
|
|
@ -14335,7 +14483,8 @@ export class TeamProvisioningService {
|
|||
|
||||
const provisioningEnv = await this.buildProvisioningEnv(
|
||||
request.providerId,
|
||||
request.providerBackendId
|
||||
request.providerBackendId,
|
||||
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) }
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
|
|
@ -15318,14 +15467,27 @@ export class TeamProvisioningService {
|
|||
member.bootstrapConfirmed !== true
|
||||
);
|
||||
};
|
||||
const updatedAtMs = Date.parse(persistedLaunchState.updatedAt);
|
||||
const activeLaunchLooksStale =
|
||||
launchPhase === 'active' &&
|
||||
(!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs >= MEMBER_LAUNCH_GRACE_MS);
|
||||
const launchOutcomeIsSettledOrStale = launchPhase !== 'active' || activeLaunchLooksStale;
|
||||
const hasPreviousExpectedTeammates = prevExpected.length > 0;
|
||||
const previousTeammates = prevExpected.map((name) => prevMembers[name]);
|
||||
const staleActiveLaunchHasNoLiveTeammates =
|
||||
activeLaunchLooksStale &&
|
||||
hasPreviousExpectedTeammates &&
|
||||
!previousTeammates.some(
|
||||
(member) => member?.runtimeAlive === true || member?.bootstrapConfirmed === true
|
||||
);
|
||||
const allTeammatesNeverSpawned =
|
||||
launchPhase !== 'active' &&
|
||||
prevExpected.length > 0 &&
|
||||
prevExpected.every((name) => teammateWasNeverSpawned(prevMembers[name]));
|
||||
if (allTeammatesNeverSpawned) {
|
||||
launchOutcomeIsSettledOrStale &&
|
||||
hasPreviousExpectedTeammates &&
|
||||
previousTeammates.every(teammateWasNeverSpawned);
|
||||
if (allTeammatesNeverSpawned || staleActiveLaunchHasNoLiveTeammates) {
|
||||
skipResume = true;
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous launch had no teammates successfully spawned — ` +
|
||||
`[${request.teamName}] Previous launch cannot be resumed safely - ` +
|
||||
`skipping session resume to allow full bootstrap`
|
||||
);
|
||||
}
|
||||
|
|
@ -15428,7 +15590,8 @@ export class TeamProvisioningService {
|
|||
|
||||
const provisioningEnv = await this.buildProvisioningEnv(
|
||||
request.providerId,
|
||||
request.providerBackendId
|
||||
request.providerBackendId,
|
||||
{ includeCodexTeammateAuth: teamRequestIncludesCodexMember(request) }
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
|
|
@ -17797,6 +17960,53 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
private markUnconfirmedBootstrapMembersFailed(
|
||||
run: ProvisioningRun,
|
||||
reason: string,
|
||||
options?: { cleanupRequested?: boolean }
|
||||
): void {
|
||||
const failedAt = nowIso();
|
||||
const baseReason = reason.trim() || 'Deterministic bootstrap failed before teammate check-in.';
|
||||
for (const expected of run.expectedMembers) {
|
||||
const prev = run.memberSpawnStatuses.get(expected) ?? createInitialMemberSpawnStatusEntry();
|
||||
if (prev.bootstrapConfirmed || prev.skippedForLaunch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runtimeWasAlive = prev.runtimeAlive === true || prev.livenessSource === 'process';
|
||||
const hardFailureReason = runtimeWasAlive
|
||||
? `${baseReason} Runtime process was alive after bootstrap failure${
|
||||
options?.cleanupRequested ? '; launch-owned cleanup requested.' : '.'
|
||||
}`
|
||||
: baseReason;
|
||||
const next: MemberSpawnStatusEntry = {
|
||||
...prev,
|
||||
status: 'error',
|
||||
updatedAt: failedAt,
|
||||
error: hardFailureReason,
|
||||
hardFailure: true,
|
||||
hardFailureReason,
|
||||
bootstrapConfirmed: false,
|
||||
bootstrapStalled: undefined,
|
||||
runtimeAlive: options?.cleanupRequested ? false : prev.runtimeAlive,
|
||||
livenessSource: options?.cleanupRequested ? undefined : prev.livenessSource,
|
||||
runtimeDiagnostic: runtimeWasAlive
|
||||
? options?.cleanupRequested
|
||||
? 'Bootstrap failed before teammate check-in; launch-owned runtime cleanup requested.'
|
||||
: 'Bootstrap failed before teammate check-in while runtime process was still alive.'
|
||||
: prev.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtimeWasAlive ? 'warning' : prev.runtimeDiagnosticSeverity,
|
||||
launchState: 'failed_to_start',
|
||||
};
|
||||
|
||||
run.memberSpawnStatuses.set(expected, next);
|
||||
this.appendMemberBootstrapDiagnostic(run, expected, hardFailureReason);
|
||||
if (this.isCurrentTrackedRun(run)) {
|
||||
this.emitMemberSpawnChange(run, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attachLiveRuntimeMetadataToStatuses(
|
||||
teamName: string,
|
||||
statuses: Record<string, MemberSpawnStatusEntry>,
|
||||
|
|
@ -22293,8 +22503,25 @@ export class TeamProvisioningService {
|
|||
cliLogsTail: extractCliLogsFromRun(run),
|
||||
});
|
||||
run.onProgress(progress);
|
||||
const hasConfirmedBootstrapMember = Array.from(run.memberSpawnStatuses.values()).some(
|
||||
(member) => member.bootstrapConfirmed === true
|
||||
);
|
||||
const shouldCleanupUnconfirmedLaunchRuntimes = run.isLaunch && !hasConfirmedBootstrapMember;
|
||||
this.markUnconfirmedBootstrapMembersFailed(run, classification.normalizedReason, {
|
||||
cleanupRequested: shouldCleanupUnconfirmedLaunchRuntimes,
|
||||
});
|
||||
if (shouldCleanupUnconfirmedLaunchRuntimes) {
|
||||
this.stopPersistentTeamMembers(run.teamName);
|
||||
}
|
||||
run.processKilled = true;
|
||||
killTeamProcess(run.child);
|
||||
void this.persistLaunchStateSnapshot(run, 'finished').catch((error: unknown) => {
|
||||
logger.warn(
|
||||
`[${run.teamName}] Failed to persist failed bootstrap launch snapshot: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
});
|
||||
this.cleanupRun(run);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -24691,6 +24918,11 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
|
||||
this.markUnconfirmedBootstrapMembersFailed(
|
||||
run,
|
||||
'Launch ended before teammate bootstrap completed.',
|
||||
{ cleanupRequested: true }
|
||||
);
|
||||
void this.persistLaunchStateSnapshot(run, 'finished');
|
||||
}
|
||||
this.resetRuntimeToolActivity(run);
|
||||
|
|
@ -25261,7 +25493,8 @@ export class TeamProvisioningService {
|
|||
|
||||
private async buildProvisioningEnv(
|
||||
providerId: TeamProviderId | undefined = 'anthropic',
|
||||
providerBackendId?: string | null
|
||||
providerBackendId?: string | null,
|
||||
options?: { includeCodexTeammateAuth?: boolean }
|
||||
): Promise<ProvisioningEnvResolution> {
|
||||
const shellEnv = await resolveInteractiveShellEnv();
|
||||
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode
|
||||
|
|
@ -25313,6 +25546,13 @@ export class TeamProvisioningService {
|
|||
});
|
||||
const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId];
|
||||
const providerEnv = providerEnvResult.env;
|
||||
if (options?.includeCodexTeammateAuth && resolvedProviderId !== 'codex') {
|
||||
await this.providerConnectionService.augmentConfiguredConnectionEnv(
|
||||
providerEnv,
|
||||
'codex',
|
||||
getConfiguredRuntimeBackend('codex')
|
||||
);
|
||||
}
|
||||
Object.assign(providerEnv, await this.buildRuntimeTurnSettledEnvironment(resolvedProviderId));
|
||||
|
||||
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
||||
|
|
|
|||
|
|
@ -343,7 +343,8 @@ describe('team-fs-worker integration', () => {
|
|||
JSON.stringify({
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt: '2026-05-02T12:00:00.000Z',
|
||||
updatedAt: new Date().toISOString(),
|
||||
launchPhase: 'active',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 1,
|
||||
pendingCount: 1,
|
||||
|
|
@ -376,6 +377,118 @@ describe('team-fs-worker integration', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('ignores stale pending launch-summary fallbacks so offline teams do not stay reconciling', async () => {
|
||||
const workerPath = await getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
const teamName = 'stale-pending-summary-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Stale Pending Summary Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-summary.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchUpdatedAt: '2026-04-09T20:35:57.962Z',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 1,
|
||||
pendingCount: 1,
|
||||
permissionPendingCount: 0,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const worker = createWorker(workerPath);
|
||||
try {
|
||||
const first = await callListTeams(worker, tempDir);
|
||||
expect(first.teams[0]).toMatchObject({ teamName });
|
||||
expect(first.teams[0]).not.toMatchObject({
|
||||
teamLaunchState: 'partial_pending',
|
||||
});
|
||||
expect(first.diag?.cacheMisses).toBe(1);
|
||||
expect(first.diag?.cacheWriteSkips).toBe(0);
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
it('rereads launch-summary after caching a stale pending fallback as settled', async () => {
|
||||
const workerPath = await getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
const teamName = 'stale-pending-cache-invalidation-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
const launchSummaryPath = path.join(teamDir, 'launch-summary.json');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Stale Pending Cache Invalidation Team',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
launchSummaryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchUpdatedAt: '2026-04-09T20:35:57.962Z',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 1,
|
||||
pendingCount: 1,
|
||||
permissionPendingCount: 0,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const worker = createWorker(workerPath);
|
||||
try {
|
||||
const stale = await callListTeams(worker, tempDir);
|
||||
expect(stale.teams[0]).toMatchObject({ teamName });
|
||||
expect(stale.teams[0]).not.toMatchObject({
|
||||
teamLaunchState: 'partial_pending',
|
||||
});
|
||||
expect(stale.diag?.cacheMisses).toBe(1);
|
||||
expect(stale.diag?.cacheWriteSkips).toBe(0);
|
||||
|
||||
await fs.writeFile(
|
||||
launchSummaryPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
teamName,
|
||||
updatedAt: new Date().toISOString(),
|
||||
launchPhase: 'active',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 1,
|
||||
pendingCount: 1,
|
||||
permissionPendingCount: 0,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const fresh = await callListTeams(worker, tempDir);
|
||||
expect(fresh.teams[0]).toMatchObject({
|
||||
teamName,
|
||||
teamLaunchState: 'partial_pending',
|
||||
pendingCount: 1,
|
||||
});
|
||||
expect(fresh.diag?.cacheHits).toBe(0);
|
||||
expect(fresh.diag?.cacheMisses).toBe(1);
|
||||
expect(fresh.diag?.cacheWriteSkips).toBe(1);
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => {
|
||||
const workerPath = await getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
|
|
|
|||
|
|
@ -49,6 +49,192 @@ describe('TeamLaunchSummaryProjection', () => {
|
|||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stale terminal launch-state pending summaries before projecting renderer copy', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSnapshot: {
|
||||
version: 2,
|
||||
teamName: 'atlas-hq-2',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'finished',
|
||||
expectedMembers: ['alice', 'jack'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
jack: {
|
||||
name: 'jack',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
} as never,
|
||||
bootstrapSnapshot: null,
|
||||
launchSummaryProjection: null,
|
||||
});
|
||||
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stale active launch-state pending summaries without outstanding permissions', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSnapshot: {
|
||||
version: 2,
|
||||
teamName: 'vector-room-13',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice', 'bob'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
bob: {
|
||||
name: 'bob',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
} as never,
|
||||
launchSummaryProjection: null,
|
||||
});
|
||||
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps stale launch-state pending summaries when permission approval is outstanding', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSnapshot: {
|
||||
version: 2,
|
||||
teamName: 'permission-team',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'permission_pending',
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-09T20:35:57.962Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
permissionPendingCount: 1,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
} as never,
|
||||
launchSummaryProjection: null,
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
teamLaunchState: 'partial_pending',
|
||||
permissionPendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale pending launch-summary projections when canonical launch truth is missing', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSummaryProjection: {
|
||||
version: 1,
|
||||
teamName: 'atlas-hq-2',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchUpdatedAt: '2026-04-09T20:35:57.962Z',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 2,
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stale active pending launch-summary projections without outstanding permissions', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSummaryProjection: {
|
||||
version: 1,
|
||||
teamName: 'vector-room-13',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchUpdatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchPhase: 'active',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 2,
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps stale pending launch-summary projections when permission approval is outstanding', () => {
|
||||
const summary = choosePreferredLaunchStateSummary({
|
||||
launchSummaryProjection: {
|
||||
version: 1,
|
||||
teamName: 'permission-team',
|
||||
updatedAt: '2026-04-09T20:35:57.962Z',
|
||||
launchUpdatedAt: '2026-04-09T20:35:57.962Z',
|
||||
teamLaunchState: 'partial_pending',
|
||||
expectedMemberCount: 1,
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
permissionPendingCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
teamLaunchState: 'partial_pending',
|
||||
permissionPendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a mixed-aware persisted summary projection over a newer but poorer bootstrap snapshot', () => {
|
||||
const bootstrapSnapshot = {
|
||||
version: 2,
|
||||
|
|
@ -219,6 +405,7 @@ describe('TeamLaunchSummaryProjection', () => {
|
|||
confirmedMemberCount: 1,
|
||||
missingMembers: ['bob'],
|
||||
failedCount: 1,
|
||||
launchPhase: 'finished',
|
||||
teamLaunchState: 'partial_failure',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -245,12 +245,16 @@ function writeLaunchConfig(
|
|||
function writeLaunchState(
|
||||
teamName: string,
|
||||
leadSessionId: string,
|
||||
members: Record<string, Record<string, unknown>>
|
||||
members: Record<string, Record<string, unknown>>,
|
||||
options?: {
|
||||
launchPhase?: 'active' | 'finished';
|
||||
updatedAt?: string;
|
||||
}
|
||||
): void {
|
||||
const snapshot = createPersistedLaunchSnapshot({
|
||||
teamName,
|
||||
leadSessionId,
|
||||
launchPhase: 'finished',
|
||||
launchPhase: options?.launchPhase ?? 'finished',
|
||||
expectedMembers: Object.keys(members),
|
||||
members: Object.fromEntries(
|
||||
Object.entries(members).map(([name, member]) => [
|
||||
|
|
@ -268,6 +272,7 @@ function writeLaunchState(
|
|||
},
|
||||
])
|
||||
) as any,
|
||||
...(options?.updatedAt ? { updatedAt: options.updatedAt } : {}),
|
||||
});
|
||||
fs.writeFileSync(
|
||||
getTeamLaunchStatePath(teamName),
|
||||
|
|
@ -709,6 +714,65 @@ describe('TeamProvisioningService', () => {
|
|||
|
||||
expect(complete).toHaveBeenCalledWith(run);
|
||||
});
|
||||
|
||||
it('finalizes unconfirmed launch members as failed before cleanup removes the run', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createClaudeLogsRun({
|
||||
runId: 'run-cleanup-finalizes-launch',
|
||||
teamName: 'cleanup-finalizes-launch-team',
|
||||
isLaunch: true,
|
||||
provisioningComplete: false,
|
||||
cancelRequested: false,
|
||||
expectedMembers: ['alice', 'bob'],
|
||||
provisioningOutputParts: [],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
livenessSource: 'process',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
const persist = vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null);
|
||||
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
(svc as any).provisioningRunByTeam.set(run.teamName, run.runId);
|
||||
|
||||
(svc as any).cleanupRun(run);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
});
|
||||
expect((svc as any).buildLiveLaunchSnapshotForRun(run, 'finished')).toMatchObject({
|
||||
teamLaunchState: 'partial_failure',
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
},
|
||||
});
|
||||
expect(persist).toHaveBeenCalledWith(run, 'finished');
|
||||
});
|
||||
});
|
||||
|
||||
describe('member spawn status launch reads', () => {
|
||||
|
|
@ -4069,6 +4133,220 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('persists verified OpenCode bridge runtime pids so member cards can show memory', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue(
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 45678'
|
||||
);
|
||||
|
||||
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||
(svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
writeLaunchState('team-a', 'lead-session', {
|
||||
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: 'opencode-run-bob',
|
||||
runtimeSessionId: 'oc-session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
},
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
|
||||
{
|
||||
pid: 456,
|
||||
ppid: 1,
|
||||
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 45678',
|
||||
},
|
||||
]);
|
||||
vi.mocked(pidusage).mockReset();
|
||||
vi.mocked(pidusage).mockImplementation(
|
||||
async (target: number | string | Array<number | string>) => {
|
||||
if (Array.isArray(target)) {
|
||||
return {
|
||||
'456': createPidusageStat(456, 456_000_000),
|
||||
} as any;
|
||||
}
|
||||
if (target === 456) {
|
||||
return createPidusageStat(456, 456_000_000) as any;
|
||||
}
|
||||
throw new Error(`Unexpected pidusage target: ${String(target)}`);
|
||||
}
|
||||
);
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
).resolves.toMatchObject({ delivered: true });
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath('team-a'), 'utf8'));
|
||||
expect(persisted.members.bob).toMatchObject({
|
||||
runtimePid: 456,
|
||||
pidSource: 'opencode_bridge',
|
||||
runtimeSessionId: 'oc-session-bob',
|
||||
});
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('team-a');
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
pid: 456,
|
||||
runtimePid: 456,
|
||||
rssBytes: 456_000_000,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist OpenCode bridge runtime pids when process identity is not verified', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePid: 456,
|
||||
diagnostics: [],
|
||||
}));
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
sendMessageToMember,
|
||||
} as any,
|
||||
]);
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
vi.spyOn(svc as any, 'readProcessCommandByPid').mockReturnValue('/usr/bin/yes');
|
||||
|
||||
(svc as any).getTrackedRunId = vi.fn(() => 'run-1');
|
||||
(svc as any).provisioningRunByTeam.set('team-a', 'run-1');
|
||||
(svc as any).setSecondaryRuntimeRun({
|
||||
teamName: 'team-a',
|
||||
runId: 'opencode-run-bob',
|
||||
providerId: 'opencode',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
writeLaunchState('team-a', 'lead-session', {
|
||||
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: 'opencode-run-bob',
|
||||
runtimeSessionId: 'oc-session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
},
|
||||
});
|
||||
await writeDefaultBobOpenCodeBootstrapEvidence();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
projectPath: '/repo',
|
||||
members: [
|
||||
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
|
||||
{ name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
launchIdentity: { providerId: 'codex' },
|
||||
providerId: 'codex',
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-1',
|
||||
})
|
||||
).resolves.toMatchObject({ delivered: true });
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath('team-a'), 'utf8'));
|
||||
expect(persisted.members.bob.runtimePid).toBeUndefined();
|
||||
expect(persisted.members.bob.pidSource).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses snapshot config reads for OpenCode member delivery routing', async () => {
|
||||
const getConfig = vi.fn(async () => {
|
||||
throw new Error('verified config read should not be used for delivery routing');
|
||||
|
|
@ -10988,6 +11266,136 @@ describe('TeamProvisioningService', () => {
|
|||
expect(launchArgs).not.toContain(leadSessionId);
|
||||
});
|
||||
|
||||
it('skips --resume when a stale active launch state shows no teammate ever spawned', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
|
||||
|
||||
const teamName = 'resume-skip-stale-active-team';
|
||||
const leadSessionId = 'lead-session-skip-stale-active';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
|
||||
writeLaunchState(
|
||||
teamName,
|
||||
leadSessionId,
|
||||
{
|
||||
alice: {
|
||||
launchState: 'starting',
|
||||
hardFailure: false,
|
||||
},
|
||||
bob: {
|
||||
launchState: 'starting',
|
||||
hardFailure: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
launchPhase: 'active',
|
||||
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockImplementation(() => {
|
||||
throw new Error('launch spawn EINVAL');
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
|
||||
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
||||
'launch spawn EINVAL'
|
||||
);
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toBeTruthy();
|
||||
expect(launchArgs).not.toContain('--resume');
|
||||
expect(launchArgs).not.toContain(leadSessionId);
|
||||
});
|
||||
|
||||
it('skips --resume when a stale active launch has accepted but no live teammates', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-03T12:03:00.000Z'));
|
||||
|
||||
const teamName = 'resume-skip-stale-active-dead-team';
|
||||
const leadSessionId = 'lead-session-skip-stale-active-dead';
|
||||
writeLaunchConfig(teamName, tempClaudeRoot, leadSessionId, ['alice', 'bob']);
|
||||
writeLaunchState(
|
||||
teamName,
|
||||
leadSessionId,
|
||||
{
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
firstSpawnAcceptedAt: '2026-05-03T12:00:15.000Z',
|
||||
hardFailure: false,
|
||||
},
|
||||
bob: {
|
||||
launchState: 'starting',
|
||||
hardFailure: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
launchPhase: 'active',
|
||||
updatedAt: '2026-05-03T12:00:00.000Z',
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockImplementation(() => {
|
||||
throw new Error('launch spawn EINVAL');
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
||||
removeConfigFile: vi.fn(async () => {}),
|
||||
} as any);
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { ANTHROPIC_API_KEY: 'test' },
|
||||
authSource: 'anthropic_api_key',
|
||||
}));
|
||||
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||
members: [{ name: 'alice' }, { name: 'bob' }],
|
||||
source: 'members-meta',
|
||||
warning: undefined,
|
||||
}));
|
||||
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||
(svc as any).pathExists = vi.fn(async (targetPath: string) =>
|
||||
targetPath.endsWith(`${leadSessionId}.jsonl`)
|
||||
);
|
||||
|
||||
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
||||
'launch spawn EINVAL'
|
||||
);
|
||||
|
||||
const launchArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(launchArgs).toBeTruthy();
|
||||
expect(launchArgs).not.toContain('--resume');
|
||||
expect(launchArgs).not.toContain(leadSessionId);
|
||||
});
|
||||
|
||||
it('keeps --resume when a teammate had an accepted spawn before failing bootstrap', async () => {
|
||||
allowConsoleLogs();
|
||||
const teamName = 'resume-keep-team';
|
||||
|
|
|
|||
Loading…
Reference in a new issue