fix: clear stale team launch reconciliation

This commit is contained in:
777genius 2026-05-03 15:00:16 +03:00
parent d495f8d8f2
commit 1a872b592a
10 changed files with 1073 additions and 14 deletions

View file

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

View file

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

View file

@ -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.
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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