diff --git a/packages/daemon/src/domain/claude-compaction-enforcer.ts b/packages/daemon/src/domain/claude-compaction-enforcer.ts index 55e4103d7..39e19ae27 100644 --- a/packages/daemon/src/domain/claude-compaction-enforcer.ts +++ b/packages/daemon/src/domain/claude-compaction-enforcer.ts @@ -788,7 +788,12 @@ export class ClaudeCompactionEnforcer { }), ); if (!prep.ok) { - return this.recordManualFailure(input.sessionName, prep.reason ?? "send_failed"); + return this.recordManualFailure( + input.sessionName, + prep.reason ?? "send_failed", + undefined, + startingInvalidationEpoch, + ); } // Phase 2 — WAIT for the prep turn to complete (seat idle), THEN send @@ -856,9 +861,15 @@ export class ClaudeCompactionEnforcer { input.sessionName, preCompactCheck.reason, preCompactCheck.decisionId, + startingInvalidationEpoch, ); } - return this.recordManualFailure(input.sessionName, compact.reason ?? "send_failed"); + return this.recordManualFailure( + input.sessionName, + compact.reason ?? "send_failed", + undefined, + startingInvalidationEpoch, + ); } // Seed the EXISTING post-compact back-half (turn_boundary → restore_prompt @@ -919,8 +930,18 @@ export class ClaudeCompactionEnforcer { this.manualCompactionState.set(sessionName, { stage, reason, updatedAt: Date.now(), operatorInitiated }); } - private recordManualFailure(sessionName: string, reason: string, decisionId?: string): ManualCompactionOutcome { - this.setManualStage(sessionName, "skipped-or-failed", reason); + private recordManualFailure( + sessionName: string, + reason: string, + decisionId?: string, + expectedInvalidationEpoch?: number, + ): ManualCompactionOutcome { + if ( + expectedInvalidationEpoch === undefined + || (this.occupantInvalidationEpoch.get(sessionName) ?? 0) === expectedInvalidationEpoch + ) { + this.setManualStage(sessionName, "skipped-or-failed", reason); + } return { triggered: false, stage: "skipped-or-failed", diff --git a/packages/daemon/test/claude-compaction-enforcer.test.ts b/packages/daemon/test/claude-compaction-enforcer.test.ts index 79d629e19..83653e472 100644 --- a/packages/daemon/test/claude-compaction-enforcer.test.ts +++ b/packages/daemon/test/claude-compaction-enforcer.test.ts @@ -990,6 +990,73 @@ describe("ClaudeCompactionEnforcer", () => { await first; }); + it("a retiring invocation refusal does not clobber its same-name successor's preparing marker", async () => { + const settings = makeSettingsStore(POLICY_ENABLED_AT_80); + let reachRetireeBoundary!: () => void; + let releaseRetiree!: () => void; + let reachSuccessorBoundary!: () => void; + let releaseSuccessor!: () => void; + const retireeAtBoundary = new Promise((resolve) => { reachRetireeBoundary = resolve; }); + const retireeReleased = new Promise((resolve) => { releaseRetiree = resolve; }); + const successorAtBoundary = new Promise((resolve) => { reachSuccessorBoundary = resolve; }); + const successorReleased = new Promise((resolve) => { releaseSuccessor = resolve; }); + let compactAttempt = 0; + const send = vi.fn(async ( + _sessionName: string, + message: string, + opts?: { beforeSend?: () => { reason: string; error?: string } | null }, + ) => { + if (!message.startsWith("/compact")) return { ok: true as const }; + compactAttempt += 1; + if (compactAttempt === 1) { + reachRetireeBoundary(); + await retireeReleased; + } else if (compactAttempt === 2) { + reachSuccessorBoundary(); + await successorReleased; + } else { + return { ok: false as const, reason: "unexpected_duplicate" }; + } + const refusal = opts?.beforeSend?.(); + return refusal + ? { ok: false as const, sent: false, sessionName: SEAT, ...refusal } + : { ok: true as const }; + }); + const enforcer = new ClaudeCompactionEnforcer( + settings, + { send } as unknown as SessionTransport, + { openrigHome: HOME }, + ); + + const retiree = enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }); + await retireeAtBoundary; + enforcer.invalidateOccupant(SEAT); + + const successor = enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }); + await successorAtBoundary; + expect(enforcer.getManualCompactionState(SEAT)?.stage).toBe("preparing"); + + releaseRetiree(); + const retireeOutcome = await retiree; + const stateAfterRetiree = enforcer.getManualCompactionState(SEAT)?.stage; + const sendsBeforeThird = send.mock.calls.length; + const third = await enforcer.triggerManualCompact({ sessionName: SEAT, runtime: "claude-code", usedPercentage: 20 }); + const sendsAfterThird = send.mock.calls.length; + + releaseSuccessor(); + const successorOutcome = await successor; + + expect(retireeOutcome).toEqual({ + triggered: false, + stage: "skipped-or-failed", + reason: "occupant_generation_changed", + }); + expect(stateAfterRetiree).toBe("preparing"); + expect(third).toEqual({ triggered: false, stage: "skipped-or-failed", reason: "already_in_progress" }); + expect(sendsAfterThird).toBe(sendsBeforeThird); + expect(successorOutcome).toEqual({ triggered: true, stage: "compact-sent" }); + }); + it("sequential call after compact-sent but before the back-half drains: no second prep, no second /compact", async () => { const settings = makeSettingsStore(POLICY_ENABLED_AT_80); const { transport, send } = makeSessionTransport();