diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index b5d16beb..f9b17d66 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -660,6 +660,8 @@ interface ProvisioningRun { >; /** Agent tool_use_id -> teammate name for persistent teammate spawns. */ memberSpawnToolUseIds: Map; + /** Highest accepted deterministic bootstrap event sequence for this run. */ + lastDeterministicBootstrapSeq: number; /** Throttles config/inbox audit work triggered by frequent status polling. */ lastMemberSpawnAuditAt: number; /** Throttles repeated audit warnings when config.json is temporarily unreadable. */ @@ -707,6 +709,33 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { }; } +export function shouldAcceptDeterministicBootstrapEvent(params: { + runId: string; + teamName: string; + lastSeq: number; + msg: Record; +}): { accept: boolean; nextSeq: number } { + const msgRunId = typeof params.msg.run_id === 'string' ? params.msg.run_id.trim() : ''; + if (msgRunId && msgRunId !== params.runId) { + return { accept: false, nextSeq: params.lastSeq }; + } + + const msgTeamName = typeof params.msg.team_name === 'string' ? params.msg.team_name.trim() : ''; + if (msgTeamName && msgTeamName !== params.teamName) { + return { accept: false, nextSeq: params.lastSeq }; + } + + const seq = typeof params.msg.seq === 'number' ? params.msg.seq : NaN; + if (Number.isFinite(seq)) { + if (!Number.isInteger(seq) || seq <= params.lastSeq) { + return { accept: false, nextSeq: params.lastSeq }; + } + return { accept: true, nextSeq: seq }; + } + + return { accept: true, nextSeq: params.lastSeq }; +} + function deriveMemberLaunchState(entry: { agentToolAccepted?: boolean; runtimeAlive?: boolean; @@ -4386,6 +4415,7 @@ export class TeamProvisioningService { request.members.map((m) => [m.name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, lastMemberSpawnAuditMissingWarningAt: new Map(), @@ -4899,6 +4929,7 @@ export class TeamProvisioningService { expectedMembers.map((name) => [name, createInitialMemberSpawnStatusEntry()]) ), memberSpawnToolUseIds: new Map(), + lastDeterministicBootstrapSeq: 0, lastMemberSpawnAuditAt: 0, lastMemberSpawnAuditConfigReadWarningAt: 0, lastMemberSpawnAuditMissingWarningAt: new Map(), @@ -7069,6 +7100,17 @@ export class TeamProvisioningService { return false; } + const acceptance = shouldAcceptDeterministicBootstrapEvent({ + runId: run.runId, + teamName: run.teamName, + lastSeq: run.lastDeterministicBootstrapSeq, + msg, + }); + if (!acceptance.accept) { + return true; + } + run.lastDeterministicBootstrapSeq = acceptance.nextSeq; + const event = typeof msg.event === 'string' ? msg.event : undefined; if (!event) { return true; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index b67b2b7b..c94ace1f 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -100,6 +100,7 @@ describe('TeamProvisioningService', () => { fs.mkdirSync(tempProjectsBase, { recursive: true }); }); + afterEach(() => { vi.useRealTimers(); try { diff --git a/test/main/services/team/TeamProvisioningServiceDeterministicBootstrapEvents.test.ts b/test/main/services/team/TeamProvisioningServiceDeterministicBootstrapEvents.test.ts new file mode 100644 index 00000000..bac238ef --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceDeterministicBootstrapEvents.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldAcceptDeterministicBootstrapEvent } from '@main/services/team/TeamProvisioningService'; + +describe('TeamProvisioningService deterministic bootstrap event ordering', () => { + it('accepts newer in-order bootstrap events', () => { + expect( + shouldAcceptDeterministicBootstrapEvent({ + runId: 'run-1', + teamName: 'atlas-hq', + lastSeq: 2, + msg: { + run_id: 'run-1', + team_name: 'atlas-hq', + seq: 3, + }, + }) + ).toEqual({ accept: true, nextSeq: 3 }); + }); + + it('rejects replayed or out-of-order bootstrap events', () => { + expect( + shouldAcceptDeterministicBootstrapEvent({ + runId: 'run-1', + teamName: 'atlas-hq', + lastSeq: 3, + msg: { + run_id: 'run-1', + team_name: 'atlas-hq', + seq: 2, + }, + }) + ).toEqual({ accept: false, nextSeq: 3 }); + }); + + it('rejects bootstrap events for another run or team', () => { + expect( + shouldAcceptDeterministicBootstrapEvent({ + runId: 'run-1', + teamName: 'atlas-hq', + lastSeq: 1, + msg: { + run_id: 'run-2', + team_name: 'atlas-hq', + seq: 2, + }, + }) + ).toEqual({ accept: false, nextSeq: 1 }); + + expect( + shouldAcceptDeterministicBootstrapEvent({ + runId: 'run-1', + teamName: 'atlas-hq', + lastSeq: 1, + msg: { + run_id: 'run-1', + team_name: 'forge-labs', + seq: 2, + }, + }) + ).toEqual({ accept: false, nextSeq: 1 }); + }); +});