Compare commits

...
Author SHA1 Message Date
core-builder-b 3e62be273c fix(daemon): a handover cleans up the retiring occupant's own generation
A seat handover gives the occupant invalidator the retiring generation, so
that the retiring occupant's in-progress queue claims and armed watchdog
jobs are released. It looked that generation up by session name, which
takes the node of the newest session row with that name.

An earlier generation of the same seat can hold a newer row under that
name. For example: re-running a rig spec archives the stopped rig and
creates a new one with the same name. The old rig is then unarchived and
restored while the new one is stopped, and archived again. Handing over
the new rig's seat then gave the invalidator the old node's generation.
The retiring occupant's own claims and jobs stayed in place.

The handover now reads the generation from the node it is handing over.
That tenure was already read on the next line. The reclaim of stale pane
claims and the refusal for a live owner are unchanged.
2026-11-12 01:37:32 +00:00
2 changed files with 32 additions and 4 deletions

No files matched your search

@@ -1335,10 +1335,11 @@ export class SeatHandoverService {
});
// (e/Class-B): capture the RETIRING occupant's generation BEFORE registerClaimedSession mints the
// successor's tenure below — after that mint the node's "current" generation IS the successor's
// (the name is reused), so this is the only point the retiree's generation is resolvable.
const retiringGeneration =
this.sessionRegistry.currentOccupantGenerationForSession(input.latestSession.session_name) ?? undefined;
// (the name is reused), so this is the only point the retiree's generation is resolvable. It is read by
// this node, not by its session name: an earlier generation of the seat (an archived rig of the same
// name) can hold a newer session row under that name.
const tenureBefore = this.readOccupantTenure(input.node.id);
const retiringGeneration = tenureBefore?.generationUuid ?? undefined;
// atom-B: a seat handover mints a HANDOVER-kind occupant generation (not the default 'adopt').
const newSession = this.sessionRegistry.registerClaimedSession(
input.node.id,
@@ -102,8 +102,9 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
return { runtime: "codex", launchHarness, checkReady } as unknown as RuntimeAdapter;
}
function newService(): SeatHandoverService {
function newService(extra: Partial<ConstructorParameters<typeof SeatHandoverService>[0]> = {}): SeatHandoverService {
return new SeatHandoverService({
...extra,
db,
rigRepo,
sessionRegistry,
@@ -654,6 +655,32 @@ describe("SeatHandoverService source execution (OPR.0.5.5.5)", () => {
expect(discoveryRepo.getDiscoveredSession(previous.discovered.id)?.claimedNodeId).toBe(other.id);
});
it.each(["fresh", "rebuild"])("%s hands the invalidator the handed-over seat's own generation, not a newer same-name row's", async (source) => {
// Re-running a spec archives a stopped rig and makes a new one with its name; the old one, unarchived and restored
// while the new one is stopped, records a newer session under the seat's name, and is then archived again.
const old = rigRepo.createRig("seat-rig");
const oldNode = rigRepo.addNode(old.id, "dev.impl", { runtime: "codex", cwd: "/project" });
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
rigRepo.archiveRig(old.id);
const { node, sessionId } = seedSeat();
sessionRegistry.updateStatus(sessionId, "exited");
const retiring = sessionRegistry.currentOccupantTenure(node.id)!.generationUuid;
rigRepo.unarchiveRig(old.id);
await new Promise((resolve) => setTimeout(resolve, 2)); // a later millisecond, so its row sorts newer
sessionRegistry.updateStatus(sessionRegistry.registerSession(oldNode.id, "dev-impl@seat-rig").id, "exited");
rigRepo.archiveRig(old.id);
const newer = sessionRegistry.currentOccupantTenure(oldNode.id)!.generationUuid;
expect(newer).not.toBe(retiring);
expect(sessionRegistry.currentOccupantGenerationForSession("dev-impl@seat-rig")).toBe(newer);
const invalidations: Array<{ retiringGeneration?: string }> = [];
service = newService({ occupantInvalidator: { invalidateRetiringOccupant: (input) => { invalidations.push(input); } } });
const result = await service.handover({ seatRef: "dev-impl@seat-rig", reason: "context-wall", source });
expect(result).toMatchObject({ ok: true, result: { mutated: true } });
expect(invalidations).toEqual([expect.objectContaining({ retiringSessionName: "dev-impl@seat-rig", retiringGeneration: retiring })]);
});
it.each([false, true])("a foreign pane claim refuses with a running session present=%s (#237 holds)", async (runningSession) => {
const { rig, node, sessionId } = seedSeat();
const other = rigRepo.addNode(rig.id, "dev.other", { runtime: "codex", cwd: "/project" });