diff --git a/packages/daemon/src/domain/claude-compaction-enforcer.ts b/packages/daemon/src/domain/claude-compaction-enforcer.ts index 4b561b257..55e4103d7 100644 --- a/packages/daemon/src/domain/claude-compaction-enforcer.ts +++ b/packages/daemon/src/domain/claude-compaction-enforcer.ts @@ -323,6 +323,10 @@ export class ClaudeCompactionEnforcer { // retired-generation stage → refuse. Injected resolver (atom-B's currentOccupantTenure by session). private readonly pendingStageGeneration = new Map(); private readonly pendingStageAuthorization = new Map(); + // Monotonic per-seat invalidation identity. Unlike the occupant state maps, this survives + // invalidation so an already-awaiting manual trigger can detect a same-name handover even when + // best-effort successor tenure minting failed and the durable generation still appears equal. + private readonly occupantInvalidationEpoch = new Map(); private readonly resolveOccupantGeneration?: (sessionName: string) => string | null; private readonly decisionStore?: Pick< EnforcerDecisionStore, @@ -715,6 +719,7 @@ export class ClaudeCompactionEnforcer { resolveCurrentSessionName?: (sessionName: string) => string | null; } = {}, ): Promise { + const startingInvalidationEpoch = this.occupantInvalidationEpoch.get(input.sessionName) ?? 0; const startingGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null; const activeHold = this.findAndObserveHold(input.sessionName, startingGenerationUuid); if (activeHold) { @@ -834,6 +839,13 @@ export class ClaudeCompactionEnforcer { error: `Session '${input.sessionName}' changed occupant generation during manual compaction preparation.`, }; } + if ((this.occupantInvalidationEpoch.get(input.sessionName) ?? 0) !== startingInvalidationEpoch) { + preCompactCheck.reason = "occupant_generation_changed"; + return { + reason: preCompactCheck.reason, + error: `Session '${input.sessionName}' was invalidated during manual compaction preparation.`, + }; + } return null; }, }, @@ -889,6 +901,10 @@ export class ClaudeCompactionEnforcer { * record. Occupant-scoped (no atom-B): the retiring occupant is gone, so a name match IS the ghost. */ invalidateOccupant(sessionName: string): void { + this.occupantInvalidationEpoch.set( + sessionName, + (this.occupantInvalidationEpoch.get(sessionName) ?? 0) + 1, + ); this.lastAutoCompactAt.delete(sessionName); this.postCompactRestoreCooldownUntil.delete(sessionName); this.triggeredAboveThreshold.delete(sessionName); diff --git a/packages/daemon/test/w4-enforcement-routes.test.ts b/packages/daemon/test/w4-enforcement-routes.test.ts index 54badb95c..70597fbb7 100644 --- a/packages/daemon/test/w4-enforcement-routes.test.ts +++ b/packages/daemon/test/w4-enforcement-routes.test.ts @@ -8,6 +8,7 @@ import { RigRepository } from "../src/domain/rig-repository.js"; import { SessionRegistry } from "../src/domain/session-registry.js"; import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js"; import { EnforcerDecisionStore } from "../src/domain/enforcer-decision-store.js"; +import { DefaultOccupantInvalidator } from "../src/domain/occupant-invalidator.js"; import { compactionRoutes } from "../src/routes/compaction.js"; const IDENTITY = "orch-lead@rig"; @@ -515,6 +516,47 @@ describe("W4 compaction-control routes", () => { expect(tx.delivered).toHaveLength(1); }); + it("the public manual trigger refuses a same-alias handover when successor tenure minting fails", async () => { + const store = new EnforcerDecisionStore(db, { + now: () => new Date("2026-08-08T18:00:00.000Z"), + authorizeTtlMinutes: () => 15, + }); + const startingGeneration = sessionRegistry.currentOccupantGenerationForSession(SEAT); + let invalidator: DefaultOccupantInvalidator; + const tx = makeBoundaryTransport(() => { + const mint = vi.spyOn(sessionRegistry, "mintOccupantTenure").mockImplementationOnce(() => { + throw new Error("forced successor tenure mint failure"); + }); + sessionRegistry.registerClaimedSession(nodeId, SEAT, "handover"); + mint.mockRestore(); + + expect(sessionRegistry.currentOccupantGenerationForSession(SEAT)).toBe(startingGeneration); + expect( + (db.prepare("SELECT session_name FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1") + .get(nodeId) as { session_name: string }).session_name, + ).toBe(SEAT); + invalidator.invalidateRetiringOccupant({ + retiringSessionName: SEAT, + successorSessionName: SEAT, + retiringGeneration: startingGeneration ?? undefined, + }); + }); + installManualHarness(store, tx.transport); + invalidator = new DefaultOccupantInvalidator({ + enforcer: manualDeps!.enforcer, + contextUsage: { invalidateOccupantSidecar: vi.fn() }, + }); + + const response = await request("/api/compaction/trigger", { session: SEAT }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + ok: false, + reason: "occupant_generation_changed", + }); + expect(tx.delivered).toHaveLength(1); + expect(tx.delivered[0]).toContain("automatic compaction preparation"); + }); + it("lists durable observation fields without requiring a write identity", async () => { const response = await request(`/api/compaction/control?session=${encodeURIComponent(SEAT)}`); expect(response.status).toBe(200);