From ad7c4e24ad832bd69d33986f55188c776a41f78f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 23:55:09 +0300 Subject: [PATCH] perf(team): reduce launch status IO churn --- .../services/team/TeamProvisioningService.ts | 241 +++++++++++- .../team/TeamProvisioningService.test.ts | 363 +++++++++++++++++- 2 files changed, 589 insertions(+), 15 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6374cb2e..20ba67b2 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -313,6 +313,11 @@ interface OpenCodeRuntimeControlAck { observedAt: string; } +interface LaunchStateWriteResult { + snapshot: PersistedTeamLaunchSnapshot; + wrote: boolean; +} + type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -336,6 +341,7 @@ import type { LeadContextUsage, MemberLaunchState, MemberSpawnLivenessSource, + MemberSpawnStatusesSnapshot, MemberSpawnStatus, MemberSpawnStatusEntry, PersistedTeamLaunchMemberState, @@ -4505,6 +4511,8 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; + private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; + private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -4590,8 +4598,27 @@ export class TeamProvisioningService { } >(); private readonly runtimeSnapshotCacheGenerationByTeam = new Map(); + private readonly memberSpawnStatusesSnapshotCache = new Map< + string, + { + expiresAtMs: number; + generation: number; + runId: string; + snapshot: MemberSpawnStatusesSnapshot; + } + >(); + private readonly memberSpawnStatusesInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string; + promise: Promise; + } + >(); + private readonly memberSpawnStatusesCacheGenerationByTeam = new Map(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStoreQueue = new Map>(); + private readonly launchStateWrittenRunIdByTeam = new Map(); private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -4676,6 +4703,19 @@ export class TeamProvisioningService { return this.runtimeSnapshotCacheGenerationByTeam.get(teamName) ?? 0; } + private getMemberSpawnStatusesCacheGeneration(teamName: string): number { + return this.memberSpawnStatusesCacheGenerationByTeam.get(teamName) ?? 0; + } + + private invalidateMemberSpawnStatusesCache(teamName: string): void { + this.memberSpawnStatusesCacheGenerationByTeam.set( + teamName, + this.getMemberSpawnStatusesCacheGeneration(teamName) + 1 + ); + this.memberSpawnStatusesSnapshotCache.delete(teamName); + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + private invalidateRuntimeSnapshotCaches(teamName: string): void { this.runtimeSnapshotCacheGenerationByTeam.set( teamName, @@ -4687,6 +4727,27 @@ export class TeamProvisioningService { this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); } + private cloneMemberSpawnStatusesSnapshot( + snapshot: MemberSpawnStatusesSnapshot + ): MemberSpawnStatusesSnapshot { + return { + ...snapshot, + statuses: Object.fromEntries( + Object.entries(snapshot.statuses).map(([memberName, entry]) => [ + memberName, + { + ...entry, + ...(entry.pendingPermissionRequestIds + ? { pendingPermissionRequestIds: [...entry.pendingPermissionRequestIds] } + : {}), + }, + ]) + ), + ...(snapshot.expectedMembers ? { expectedMembers: [...snapshot.expectedMembers] } : {}), + ...(snapshot.summary ? { summary: { ...snapshot.summary } } : {}), + }; + } + private cloneLiveTeamAgentRuntimeMetadata( metadata: ReadonlyMap ): Map { @@ -10409,6 +10470,68 @@ export class TeamProvisioningService { return readPersistedStatuses(runId); } + if (!this.shouldCacheMemberSpawnStatusesSnapshot(run)) { + return this.buildMemberSpawnStatusesSnapshotForRun(run); + } + + const generationAtStart = this.getMemberSpawnStatusesCacheGeneration(teamName); + const cached = this.memberSpawnStatusesSnapshotCache.get(teamName); + if ( + cached && + cached.expiresAtMs > Date.now() && + cached.runId === run.runId && + cached.generation === generationAtStart + ) { + return this.cloneMemberSpawnStatusesSnapshot(cached.snapshot); + } + + const existingRequest = this.memberSpawnStatusesInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === run.runId + ) { + const snapshot = await existingRequest.promise; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + const request = this.buildMemberSpawnStatusesSnapshotForRun(run, generationAtStart).finally( + () => { + if (this.memberSpawnStatusesInFlightByTeam.get(teamName)?.promise === request) { + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + } + ); + this.memberSpawnStatusesInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: run.runId, + promise: request, + }); + const snapshot = await request; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + private shouldCacheMemberSpawnStatusesSnapshot(run: ProvisioningRun): boolean { + return run.isLaunch === true && run.provisioningComplete !== true; + } + + private async buildMemberSpawnStatusesSnapshotForRun( + run: ProvisioningRun, + generationAtStart?: number + ): Promise { + const teamName = run.teamName; await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run); await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); @@ -10428,23 +10551,37 @@ export class TeamProvisioningService { }); const rawSnapshot = liveSnapshot ?? persisted; const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); - const snapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); + const launchSnapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); const statuses = await this.attachLiveRuntimeMetadataToStatuses( teamName, - snapshotToMemberSpawnStatuses(snapshot) + snapshotToMemberSpawnStatuses(launchSnapshot) ); - const expectedMembers = this.getPersistedLaunchMemberNames(snapshot); + const expectedMembers = this.getPersistedLaunchMemberNames(launchSnapshot); const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses); - return { + const spawnSnapshot: MemberSpawnStatusesSnapshot = { statuses, - runId, + runId: run.runId, teamLaunchState: deriveTeamLaunchAggregateState(summary), - launchPhase: snapshot.launchPhase, + launchPhase: launchSnapshot.launchPhase, expectedMembers, - updatedAt: snapshot.updatedAt, + updatedAt: launchSnapshot.updatedAt, summary, source: persisted ? 'merged' : 'live', }; + if ( + generationAtStart != null && + this.shouldCacheMemberSpawnStatusesSnapshot(run) && + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + this.memberSpawnStatusesSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS, + generation: generationAtStart, + runId: run.runId, + snapshot: this.cloneMemberSpawnStatusesSnapshot(spawnSnapshot), + }); + } + return spawnSnapshot; } async getTeamAgentRuntimeSnapshot(teamName: string): Promise { @@ -18041,6 +18178,7 @@ export class TeamProvisioningService { private async clearPersistedLaunchStateNow(teamName: string): Promise { await this.launchStateStore.clear(teamName); + this.launchStateWrittenRunIdByTeam.delete(teamName); await clearBootstrapState(teamName); } @@ -18302,15 +18440,17 @@ export class TeamProvisioningService { teamName: string, snapshot: PersistedTeamLaunchSnapshot ): Promise { - return this.enqueueLaunchStateStoreOperation(teamName, () => + const result = await this.enqueueLaunchStateStoreOperation(teamName, () => this.writeLaunchStateSnapshotNow(teamName, snapshot) ); + return result.snapshot; } private async writeLaunchStateSnapshotNow( teamName: string, - snapshot: PersistedTeamLaunchSnapshot - ): Promise { + snapshot: PersistedTeamLaunchSnapshot, + options?: { allowNoopSkip?: boolean; runId?: string } + ): Promise { const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); const overlaidSnapshot = await this.applyOpenCodeSecondaryEvidenceOverlay({ @@ -18319,8 +18459,74 @@ export class TeamProvisioningService { previousSnapshot, metaMembers, }); + if ( + options?.allowNoopSkip === true && + typeof options.runId === 'string' && + this.launchStateWrittenRunIdByTeam.get(teamName) === options.runId && + previousSnapshot && + this.areLaunchStateSnapshotsSemanticallyEqual(previousSnapshot, overlaidSnapshot) && + !this.isLaunchStateNoopRefreshDue(previousSnapshot) + ) { + return { snapshot: previousSnapshot, wrote: false }; + } await this.launchStateStore.write(teamName, overlaidSnapshot); - return overlaidSnapshot; + if (typeof options?.runId === 'string') { + this.launchStateWrittenRunIdByTeam.set(teamName, options.runId); + } + return { snapshot: overlaidSnapshot, wrote: true }; + } + + private isLaunchStateNoopRefreshDue(snapshot: PersistedTeamLaunchSnapshot): boolean { + const updatedAtMs = Date.parse(snapshot.updatedAt); + return ( + !Number.isFinite(updatedAtMs) || + Date.now() - updatedAtMs >= TeamProvisioningService.LAUNCH_STATE_NOOP_REFRESH_MS + ); + } + + private areLaunchStateSnapshotsSemanticallyEqual( + left: PersistedTeamLaunchSnapshot, + right: PersistedTeamLaunchSnapshot + ): boolean { + return ( + JSON.stringify(this.toLaunchStateSemanticValue(left)) === + JSON.stringify(this.toLaunchStateSemanticValue(right)) + ); + } + + private toLaunchStateSemanticValue(snapshot: PersistedTeamLaunchSnapshot): unknown { + const { updatedAt: _updatedAt, members, ...rest } = snapshot; + const stableMembers = Object.fromEntries( + Object.entries(members) + .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) + .map(([memberName, member]) => { + const { + lastEvaluatedAt: _lastEvaluatedAt, + lastRuntimeAliveAt: _lastRuntimeAliveAt, + ...stableMember + } = member; + return [memberName, stableMember]; + }) + ); + return this.toStableJsonValue({ + ...rest, + members: stableMembers, + }); + } + + private toStableJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.toStableJsonValue(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, entryValue]) => [key, this.toStableJsonValue(entryValue)]) + ); } private async enqueueLaunchStateStoreOperation( @@ -18657,6 +18863,7 @@ export class TeamProvisioningService { run: Pick, memberName: string ) { + this.invalidateMemberSpawnStatusesCache(run.teamName); this.teamChangeEmitter?.({ type: 'member-spawn', teamName: run.teamName, @@ -19008,9 +19215,14 @@ export class TeamProvisioningService { return null; } - const writtenSnapshot = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); - this.invalidateRuntimeSnapshotCaches(run.teamName); - return writtenSnapshot; + const writeResult = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot, { + allowNoopSkip: true, + runId: run.runId, + }); + if (writeResult.wrote) { + this.invalidateRuntimeSnapshotCaches(run.teamName); + } + return writeResult.snapshot; } private async launchSingleMixedSecondaryLane( @@ -23879,6 +24091,7 @@ export class TeamProvisioningService { } if (!hasNewerTrackedRun) { this.invalidateRuntimeSnapshotCaches(run.teamName); + this.invalidateMemberSpawnStatusesCache(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 02a791b4..67a6c146 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -129,7 +129,10 @@ import { initializeAutoResumeService, } from '@main/services/team/AutoResumeService'; import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; -import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { + createPersistedLaunchSnapshot, + snapshotFromRuntimeMemberStatuses, +} from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { @@ -628,6 +631,364 @@ describe('TeamProvisioningService', () => { }); }); + describe('member spawn status launch reads', () => { + it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshDeferred = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementation(async () => refreshDeferred.promise); + const auditStatuses = vi + .spyOn(svc as any, 'maybeAuditMemberSpawnStatuses') + .mockResolvedValue(undefined); + const persistSnapshot = vi + .spyOn(svc as any, 'persistLaunchStateSnapshot') + .mockResolvedValue(null); + + const first = svc.getMemberSpawnStatuses(teamName); + const second = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + refreshDeferred.resolve(); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + + expect(firstSnapshot).toEqual(secondSnapshot); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + }); + + it('invalidates the short status cache when a real member-spawn change is emitted', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-invalidated-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + await svc.getMemberSpawnStatuses(teamName); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + }); + + it('retries the owner status request when a member-spawn change lands while it is building', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-owner-retry-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const firstRefresh = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementationOnce(async () => firstRefresh.promise) + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + const pending = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + firstRefresh.resolve(); + const result = await pending; + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + expect(result.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + }); + }); + + describe('launch-state no-op persistence guard', () => { + it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-noop-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, run.runId); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result).toBe(previousSnapshot); + expect((svc as any).launchStateStore.write).not.toHaveBeenCalled(); + expect(invalidateRuntime).not.toHaveBeenCalled(); + }); + + it('keeps a bounded launch-state heartbeat for unchanged active snapshots', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:20.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-heartbeat-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:00.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:20.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first service-owned launch-state write for an existing snapshot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-first-write-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first write for a new run even when the previous snapshot is semantically equal', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-new-run-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, 'previous-run-id'); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:05.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('writes and invalidates runtime cache when launch-state semantics change', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-change-team'; + const previousStatus = createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const nextStatus = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + updatedAt: '2026-05-02T10:00:05.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + lastHeartbeatAt: '2026-05-02T10:00:05.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: previousStatus as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', nextStatus]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + }); + describe('getTeamAgentRuntimeSnapshot', () => { it('dedupes concurrent runtime snapshot probes for the same team', async () => { const svc = new TeamProvisioningService();