feat(handover): 0.5.2-07 A4-profile — thread codex_config_profile through handover

The A2-1 model-fidelity fix's sibling, one field over. The codex adapter already
emits `-p <profile>` from binding.codexConfigProfile and the RESTORE path already
threads it; only HANDOVER dropped it — lookupNode SELECTed id/runtime/cwd/model
but not codex_config_profile, so a profile-pinned codex seat reverted to no-profile
at every handover.

Thread it the same way as model: NodeRow + lookupNode SELECT (seat-handover-service),
successor node object, SuccessorNode + transient binding (successor-session-launcher).
Adapter unchanged (already consumes binding.codexConfigProfile).

RED-first EFFECT proof: real lookupNode->createSuccessor->launchHarness path asserts
the successor binding carries the SPEC-pinned profile (mirrors the A2-1 money proof).
Enumeration guard extended to fence the profile field across the handover path.

Gate: daemon tsc 0, full daemon suite 7330 passed / 0 failed (hermetic, env-less).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 4.8 committed 2026-08-11 01:41:56 +00:00
1 parent bba1ad264a
commit 1e4bfee835
4 files changed
+58 -4

No files matched your search

@@ -80,6 +80,9 @@ interface NodeRow {
// 0.5.2-07: the seat's SPEC-pinned model, threaded onto the successor binding so handover
// does not silently revert a spec-pinned seat to the runtime default (adapter emits -m/--model).
model: string | null;
// 0.5.2-07 A4-profile: the seat's SPEC-pinned codex config profile (nodes.codex_config_profile),
// threaded onto the successor binding for the same reason as model — the adapter emits `-p <profile>`.
codex_config_profile: string | null;
}
interface SessionRow {
@@ -330,7 +333,9 @@ export class SeatHandoverService {
// Seam B: the successor is the SAME seat continuing — persisted policy posture carries.
// 0.5.2-07 model fidelity: carry the seat's SPEC-pinned model so the successor launch reads the
// spec (else the running topology drifts from the founder-designed one at every handover).
node: { id: node.id, runtime: node.runtime, cwd: node.cwd, launchPosture: successorPosture, model: node.model },
// A4-profile: likewise carry the codex config profile (adapter emits -p) — the restore path
// already threads it; handover must too, or a profile-pinned codex seat reverts at handover.
node: { id: node.id, runtime: node.runtime, cwd: node.cwd, launchPosture: successorPosture, model: node.model, codexConfigProfile: node.codex_config_profile ?? undefined },
departingSessionName: latestSession.session_name,
occupantGeneration,
...(predecessorGeneration
@@ -640,7 +645,7 @@ export class SeatHandoverService {
private lookupNode(status: SeatStatus): NodeRow {
return this.db.prepare(
"SELECT id, runtime, cwd, model FROM nodes WHERE rig_id = ? AND logical_id = ?"
"SELECT id, runtime, cwd, model, codex_config_profile FROM nodes WHERE rig_id = ? AND logical_id = ?"
).get(status.rig_id, status.logical_id) as NodeRow;
}
@@ -48,6 +48,10 @@ export interface SuccessorNode {
* running topology drift from the founder-designed one. Populated by the caller from node provenance;
* absent → the adapter emits no model flag (unchanged for legacy/unpinned seats). */
model?: string | null;
/** 0.5.2-07 A4-profile: the seat's SPEC-pinned codex config profile (nodes.codex_config_profile).
* Same continuity rationale as model — populated by the caller from node provenance; absent → the
* adapter emits no -p flag (unchanged for legacy/unpinned seats). */
codexConfigProfile?: string | null;
}
export type SuccessorLaunchResult =
@@ -285,6 +289,8 @@ export class SuccessorSessionLauncher {
...(node.launchPosture ? { launchPosture: node.launchPosture } : {}),
// 0.5.2-07: the successor reads the seat's SPEC-pinned model (adapter emits -m/--model).
model: node.model ?? undefined,
// 0.5.2-07 A4-profile: the successor reads the seat's SPEC-pinned codex config profile (adapter emits -p).
codexConfigProfile: node.codexConfigProfile ?? undefined,
};
let launch: Awaited<ReturnType<RuntimeAdapter["launchHarness"]>>;
@@ -94,4 +94,27 @@ describe("0.5.2-07 A2-3 — model-fidelity enumeration guard", () => {
// And it IS still used by the metadata/inventory surface (classification is live, not dead).
expect(callers.sort()).toEqual(["node-inventory.ts", "resume-metadata-refresher.ts"]);
});
// 0.5.2-07 A4-profile — extend the fence to the codex_config_profile field. The codex adapter already
// emits `-p` from binding.codexConfigProfile; the HANDOVER path (the A2-1 fix's sibling) must SELECT
// the column at lookupNode AND carry it onto the successor binding, exactly as it now does for model.
// A future edit that threads the profile into the binding but forgets the SELECT (or vice-versa) fails HERE.
it("handover threads codex_config_profile: lookupNode SELECTs it AND the successor binding carries it", () => {
const handover = read("domain/seat-handover-service.ts");
// The lookupNode query must SELECT the column (mirrors the A2-1 model SELECT, one field over).
expect(handover, "seat-handover-service lookupNode must SELECT codex_config_profile").toMatch(
/SELECT id, runtime, cwd, model, codex_config_profile FROM nodes/,
);
// The NodeRow shape must carry it, and createSuccessor must forward it onto the successor node.
expect(handover, "NodeRow must carry codex_config_profile").toMatch(/codex_config_profile: string \| null/);
expect(handover, "createSuccessor must forward the profile onto the successor node").toMatch(
/codexConfigProfile: node\.codex_config_profile/,
);
// The launcher must thread it from the successor node onto the transient binding the adapter reads.
const launcher = read("domain/successor-session-launcher.ts");
expect(launcher, "SuccessorNode must declare codexConfigProfile").toMatch(/codexConfigProfile\?: string \| null/);
expect(launcher, "the transient binding must thread codexConfigProfile").toMatch(
/codexConfigProfile: node\.codexConfigProfile/,
);
});
});
@@ -109,9 +109,9 @@ describe("SeatHandoverService", () => {
});
}
function seedSeat(opts?: { runtime?: string; withSession?: boolean; model?: string }) {
function seedSeat(opts?: { runtime?: string; withSession?: boolean; model?: string; codexConfigProfile?: string }) {
const rig = rigRepo.createRig("seat-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: opts?.runtime ?? "codex", cwd: "/project", model: opts?.model });
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: opts?.runtime ?? "codex", cwd: "/project", model: opts?.model, codexConfigProfile: opts?.codexConfigProfile });
let sessionId: string | null = null;
if (opts?.withSession !== false) {
const session = sessionRegistry.registerSession(node.id, "dev-impl@seat-rig");
@@ -470,6 +470,26 @@ describe("SeatHandoverService", () => {
expect(successorBinding.model).toBe("gpt-5.4-cheap");
});
it("MONEY PROOF (0.5.2-07 A4-profile): a codex_config_profile-pinned seat's handover launches the successor with that profile — the REAL lookupNode→createSuccessor→launchHarness path", async () => {
// A4-profile mirrors A2-1 for the codex_config_profile column. The codex adapter already emits
// `-p <profile>` from binding.codexConfigProfile; the gap is the HANDOVER threading — lookupNode
// must SELECT codex_config_profile and createSuccessor must carry it onto the successor binding.
// RED on this base: lookupNode SELECTs only id/runtime/cwd/model, so the pinned profile is lost
// before the successor binding is ever built — the same handover-reverts gap as the model, one
// field over. The restore path already threads it (restore-orchestrator.ts); handover did not.
seedSeat({ runtime: "codex", codexConfigProfile: "prod-sandboxed" });
const result = await service.handover({
seatRef: "dev-impl@seat-rig",
reason: "context-wall",
source: "fresh",
operator: "orch-lead@seat-rig",
});
expect(result.ok).toBe(true);
expect(launchHarness).toHaveBeenCalledTimes(1);
const successorBinding = launchHarness.mock.calls[0]![0] as { codexConfigProfile?: string };
expect(successorBinding.codexConfigProfile).toBe("prod-sandboxed");
});
it("composes the full cycle for a fresh source: create -> deliver -> verify -> rebind", async () => {
const { node } = seedSeat({ runtime: "codex" });