diff --git a/doc/plans/2026-11-07-online-git-source-fence.md b/doc/plans/2026-11-07-online-git-source-fence.md index 434287d7276..e67165c3406 100644 --- a/doc/plans/2026-11-07-online-git-source-fence.md +++ b/doc/plans/2026-11-07-online-git-source-fence.md @@ -38,6 +38,10 @@ their existing reconstruction path. Preparation, lifecycle, runtime-service, expiration, and reopen owners retain the same physical keys before filesystem mutation or lifecycle exclusion. The importer holds all cohort task/project and lifecycle keys through publication. +Each cohort reserves its at most eight members in independent transactions in +parallel. It joins every reservation before starting capture or releasing the +source fence, including on failure. Partial journals keep the full exact member +and operation set for resume; publication stays serial in one transaction. It also holds the existing per-run physical finalizer keys for each exact native recovery proof. It re-inspects the complete source proof before capture and publication. Changed descriptors, allocations, owners, aliases, or Git metadata diff --git a/server/src/services/remote-workspace-cohort-migration.test.ts b/server/src/services/remote-workspace-cohort-migration.test.ts index 25fe135d197..21cb2de46ba 100644 --- a/server/src/services/remote-workspace-cohort-migration.test.ts +++ b/server/src/services/remote-workspace-cohort-migration.test.ts @@ -313,10 +313,10 @@ describe.skipIf(!support.supported)("bounded workspace cohort migration", () => expect.soft(await db.select().from(workspaceOperations)).toHaveLength(1); await assertLocal(f); }); - it.each(["preflight", "recovery"] as const)("joins concurrent %s validation readers before releasing the fence", async phase => { + it.each(["preflight", "reservation", "recovery"] as const)("joins concurrent %s validation readers before releasing the fence", async phase => { const f = await fixture(), held = await recoverable(f), inspect = onlineWorkspaceSource.inspectOnlineWorkspaceSource; const counts = new Map(), selected = new Set(f.leaves.map(row => row.id)); - const phaseCall = phase === "preflight" ? 2 : 4; + const phaseCall = phase === "preflight" ? 2 : phase === "reservation" ? 3 : 4; let entered = 0, settled = false, bothEntered!: () => void, release!: () => void, firstFailed!: () => void; const both = new Promise(resolve => { bothEntered = resolve; }), peer = new Promise(resolve => { release = resolve; }); const failed = new Promise(resolve => { firstFailed = resolve; }), timer = setTimeout(() => bothEntered(), 5000); @@ -344,7 +344,8 @@ describe.skipIf(!support.supported)("bounded workspace cohort migration", () => await expect(withWorkspaceSourceLocks(db, { companyId: f.companyId, projectIds: [f.projectId] }, async () => true, true)).resolves.toBe(true); expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, held.runId)))[0]).toEqual(held.owner); await assertLocal(f); - expect(await db.select().from(workspaceOperations)).toHaveLength(phase === "preflight" ? 0 : 2); + expect(await db.select().from(workspaceOperations)).toHaveLength(phase === "preflight" ? 0 : phase === "reservation" ? 1 : 2); + expect(await db.select().from(executionWorkspaceSnapshots)).toHaveLength(0); expect(await db.select().from(activityLog)).toHaveLength(0); }); it.each(["failure", "cancel"] as const)("joins active capture peers and keeps the fence on %s", async mode => { diff --git a/server/src/services/remote-workspace-migration.ts b/server/src/services/remote-workspace-migration.ts index 6819091b8ac..0b686588bf5 100644 --- a/server/src/services/remote-workspace-migration.ts +++ b/server/src/services/remote-workspace-migration.ts @@ -773,13 +773,16 @@ export async function migrateRemoteWorkspaceCohort(input: { if (!await tryAcquireExecutionWorkspaceLifecycleLock(lock, workspaceId)) throw new IsolatedRepositorySourceBusy("workspace_source_busy"); } await preflight(input.db); - const plans: MigrationPlan[] = []; - for (const member of members) { + const reservations = await Promise.allSettled(members.map(async member => { const reserved = await reserveMigration(member.input, member.onlineSource, cohort, fingerprint(initial.find(workspace => workspace.id === member.input.workspaceId)!)); if (reserved.authority) throw new Error("workspace_cohort_membership_changed"); - plans.push({ ...member, reserved }); - } + return { ...member, reserved }; + })); + const plans: MigrationPlan[] = reservations.map(reservation => { + if (reservation.status === "rejected") throw reservation.reason; + return reservation.value; + }); await lockMigrationRecovery(lock, plans, cohort); const captures: Array = Array(plans.length); const childVerifier = createMigrationChildVerifier(plans[0].input, plans[0].onlineSource, lock);