fix: cancel invalidated manual compaction

This commit is contained in:
v-openrig-build committed 2026-08-09 09:55:27 +00:00
1 parent 89fd15bf59
commit d4236f5bd2
2 files changed
+58

No files matched your search

@@ -323,6 +323,10 @@ export class ClaudeCompactionEnforcer {
// retired-generation stage → refuse. Injected resolver (atom-B's currentOccupantTenure by session).
private readonly pendingStageGeneration = new Map<string, string | null>();
private readonly pendingStageAuthorization = new Map<string, PendingStageAuthorization>();
// 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<string, number>();
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<ManualCompactionOutcome> {
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);
@@ -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);