From 298f0006300011178f8d748656ac9bd6a3b680d5 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Thu, 20 Aug 2026 22:31:05 +0900 Subject: [PATCH] fix(codex): run owned startup after ownership retry --- src/codex/native-profile-startup.ts | 47 ++++++++++++++++++++++------ src/server/index.ts | 5 ++- tests/native-profile-startup.test.ts | 29 +++++++++++++++++ 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/codex/native-profile-startup.ts b/src/codex/native-profile-startup.ts index e873579197..c8ed74f09f 100644 --- a/src/codex/native-profile-startup.ts +++ b/src/codex/native-profile-startup.ts @@ -327,6 +327,7 @@ const serviceOwnershipReprobes = new Map NativeCodexOwnership; + readonly activate?: () => NativeMainStartupLifecycle; attempts: number; /** The fence that installed this hook; only its own release may drop the entry. */ readonly owner: NativeMainStartupLifecycle; @@ -369,14 +370,26 @@ function reprobeServiceOwnership(reason: NativeMainServiceOwnershipBlockReason): return false; } if (answer !== "owned") return false; + // Becoming ownable is only the beginning of startup. Arm the normal owned + // lifecycle before dropping this fence so recovery, owner registration, and + // stage cleanup all remain ahead of native-main admission. + try { + entry.activate?.(); + } catch { + return false; + } // Release through the fence that installed this hook, and only that one. // // Several servers can hold a fence for the same reason while only one carries a hook, so // clearing the shared refcount here would unblock fences this probe never spoke for. // Decrementing here directly is just as wrong the other way: that fence's own release() - // would then pay a second time for one fence, leaving the count short. Delegating to the - // fence's idempotent release keeps exactly one payment per fence. + // would then pay a second time for one fence, leaving the count short. The + // fence's idempotent spend hook keeps exactly one payment per fence while + // preserving the transitioned lifecycle until the server itself releases it. entry.spend(); + if (serviceOwnershipReprobes.get(reason) === entry) { + serviceOwnershipReprobes.delete(reason); + } return true; } @@ -395,22 +408,35 @@ function serviceOwnershipSnapshot( /** Close native-main admission without resolving or creating any CODEX_HOME artifacts. */ export function blockNativeMainStartupForUnownedServiceHome( reason: NativeMainServiceOwnershipBlockReason, - options?: { reprobe?: () => NativeCodexOwnership }, + options?: { + reprobe?: () => NativeCodexOwnership; + onOwned?: () => NativeMainStartupLifecycle; + }, ): NativeMainStartupLifecycle { serviceOwnershipRefs.set(reason, (serviceOwnershipRefs.get(reason) ?? 0) + 1); let released = false; + let fenceSpent = false; + let ownedLifecycle: NativeMainStartupLifecycle | undefined; + const spendFence = () => { + if (fenceSpent) return; + fenceSpent = true; + const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); + if (remaining === 0) serviceOwnershipRefs.delete(reason); + else serviceOwnershipRefs.set(reason, remaining); + }; const lifecycle: NativeMainStartupLifecycle = { - homeId: null, - settled: Promise.resolve(serviceOwnershipSnapshot(reason)), + get homeId() { return ownedLifecycle?.homeId ?? null; }, + get settled() { + return ownedLifecycle?.settled ?? Promise.resolve(serviceOwnershipSnapshot(reason)); + }, async release() { if (released) return; released = true; - const remaining = Math.max(0, (serviceOwnershipRefs.get(reason) ?? 0) - 1); - if (remaining === 0) serviceOwnershipRefs.delete(reason); - else serviceOwnershipRefs.set(reason, remaining); + spendFence(); if (serviceOwnershipReprobes.get(reason)?.owner === lifecycle) { serviceOwnershipReprobes.delete(reason); } + await ownedLifecycle?.release(); }, }; // Do NOT reset an existing budget: keying the reprobe by reason means a caller raising @@ -423,7 +449,10 @@ export function blockNativeMainStartupForUnownedServiceHome( probe: options.reprobe, attempts: 0, owner: lifecycle, - spend: () => { void lifecycle.release(); }, + activate: options.onOwned + ? () => (ownedLifecycle ??= options.onOwned!()) + : undefined, + spend: spendFence, }); } return lifecycle; diff --git a/src/server/index.ts b/src/server/index.ts index b33e3f1750..51c7522790 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -709,7 +709,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server inspectStartupOwnership(deps).ownership }, + { + reprobe: () => inspectStartupOwnership(deps).ownership, + onOwned: () => startNativeMainStartupLifecycle(deps.nativeMainStartup), + }, ) : { homeId: null, diff --git a/tests/native-profile-startup.test.ts b/tests/native-profile-startup.test.ts index fe59dc2224..2d5380cffe 100644 --- a/tests/native-profile-startup.test.ts +++ b/tests/native-profile-startup.test.ts @@ -35,6 +35,7 @@ import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot, NATIVE_MAIN_OWNERSHIP_RETRY_LIMIT, + startNativeMainStartupLifecycle, __resetNativeMainOwnershipRetries, } from "../src/codex/native-profile-startup"; import type { NativeCodexOwnership } from "../src/integrations/native/ownership-preflight"; @@ -661,6 +662,34 @@ describe("an unknown service-ownership fence is retryable (#2108)", () => { } }); + test("a later successful probe starts owned recovery before reopening admission", async () => { + let finishRecovery!: () => void; + const recoveryBarrier = new Promise(resolve => { finishRecovery = resolve; }); + const f = await fixture("prepared", "source-exact"); + const fence = blockNativeMainStartupForUnownedServiceHome("ownership-unknown", { + reprobe: () => "owned", + onOwned: () => startNativeMainStartupLifecycle({ + manager: f.manager, + beforeRecovery: () => recoveryBarrier, + }), + }); + try { + expect(isNativeMainTrafficBlocked()).toBe(true); + expect(nativeMainStartupGateSnapshot()).toEqual({ + status: "blocked", + homeId: f.manager.context.homeId, + reason: "recovery-pending", + }); + + finishRecovery(); + await fence.settled; + expect(isNativeMainTrafficBlocked()).toBe(false); + } finally { + finishRecovery(); + await fence.release(); + } + }); + test("a foreign owner is a fact, not a question — it never retries", () => { let asked = 0; const fence = blockNativeMainStartupForUnownedServiceHome("foreign-ownership", {