mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
fix stale-generation authorization carry
This commit is contained in:
1 parent
d789fbe515
commit
34fe36d55b
2 files changed
+104
-7
No files matched your search
@@ -294,6 +294,11 @@ function buildPostCompactTurnBoundaryPrompt(): string {
|
||||
|
||||
type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_prompt";
|
||||
type PendingPreCompactStage = "prep_prompt_sent";
|
||||
type PendingStageAuthorization = {
|
||||
decisionId: string;
|
||||
liftedReason: "stale_generation";
|
||||
generationUuid: string;
|
||||
};
|
||||
|
||||
export class ClaudeCompactionEnforcer {
|
||||
private readonly settingsStore: SettingsStore;
|
||||
@@ -317,6 +322,7 @@ export class ClaudeCompactionEnforcer {
|
||||
// unknown). At drain we compare it to the LIVE generation; a mismatch = a successor inheriting a
|
||||
// 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>();
|
||||
private readonly resolveOccupantGeneration?: (sessionName: string) => string | null;
|
||||
private readonly decisionStore?: Pick<
|
||||
EnforcerDecisionStore,
|
||||
@@ -384,6 +390,8 @@ export class ClaudeCompactionEnforcer {
|
||||
let authorization: {
|
||||
decisionId: string;
|
||||
liftedReason: AuthorizableCompactionReason;
|
||||
generationUuid: string | null;
|
||||
alreadyConsumed: boolean;
|
||||
} | null = null;
|
||||
const lift = (automaticReason: AuthorizableCompactionReason): boolean => {
|
||||
const found = this.decisionStore?.findMatchingAuthorization({
|
||||
@@ -393,20 +401,30 @@ export class ClaudeCompactionEnforcer {
|
||||
automaticReason,
|
||||
}) as EnforcerDecision | null | undefined;
|
||||
if (!found) return false;
|
||||
authorization = { decisionId: found.decisionId, liftedReason: automaticReason };
|
||||
authorization = {
|
||||
decisionId: found.decisionId,
|
||||
liftedReason: automaticReason,
|
||||
generationUuid: found.generationUuid ?? liveGenerationUuid,
|
||||
alreadyConsumed: false,
|
||||
};
|
||||
return true;
|
||||
};
|
||||
const sendAttempt = async (
|
||||
message: string,
|
||||
opts?: SendOpts,
|
||||
consumeAuthorization = true,
|
||||
authorizationOptions: {
|
||||
consume?: boolean;
|
||||
finalize?: boolean;
|
||||
} = {},
|
||||
): Promise<{ ok: boolean; result: SendResult | null; outcome: EnforcerOutcome }> => {
|
||||
const lifted = authorization as {
|
||||
decisionId: string;
|
||||
liftedReason: AuthorizableCompactionReason;
|
||||
generationUuid: string | null;
|
||||
alreadyConsumed: boolean;
|
||||
} | null;
|
||||
let consumed = false;
|
||||
if (lifted && consumeAuthorization) {
|
||||
let consumed = lifted?.alreadyConsumed ?? false;
|
||||
if (lifted && (authorizationOptions.consume ?? true) && !consumed) {
|
||||
consumed = this.decisionStore?.consumeAuthorizationForAttempt({
|
||||
decisionId: lifted.decisionId,
|
||||
enforcerKind: "claude_compaction",
|
||||
@@ -419,6 +437,13 @@ export class ClaudeCompactionEnforcer {
|
||||
outcome: { triggered: false, reason: lifted.liftedReason },
|
||||
};
|
||||
}
|
||||
if (lifted.liftedReason === "stale_generation" && lifted.generationUuid) {
|
||||
this.pendingStageAuthorization.set(input.sessionName, {
|
||||
decisionId: lifted.decisionId,
|
||||
liftedReason: lifted.liftedReason,
|
||||
generationUuid: lifted.generationUuid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: SendResult;
|
||||
@@ -441,7 +466,7 @@ export class ClaudeCompactionEnforcer {
|
||||
outcome: { triggered: false, reason: "send_failed" },
|
||||
};
|
||||
}
|
||||
if (lifted && consumed) {
|
||||
if (lifted && consumed && (!result.ok || (authorizationOptions.finalize ?? true))) {
|
||||
this.decisionStore?.recordAuthorizationAttempt({
|
||||
decisionId: lifted.decisionId,
|
||||
outcome: result.ok ? "succeeded" : "failed",
|
||||
@@ -509,7 +534,13 @@ export class ClaudeCompactionEnforcer {
|
||||
if (stageGen != null) {
|
||||
const liveGen = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
|
||||
if (liveGen != null && liveGen !== stageGen) {
|
||||
if (!lift("stale_generation")) {
|
||||
const carried = this.pendingStageAuthorization.get(input.sessionName);
|
||||
if (carried?.generationUuid === liveGen) {
|
||||
authorization = { ...carried, alreadyConsumed: true };
|
||||
} else {
|
||||
this.pendingStageAuthorization.delete(input.sessionName);
|
||||
}
|
||||
if (!authorization && !lift("stale_generation")) {
|
||||
this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage
|
||||
return { triggered: false, reason: "stale_generation" };
|
||||
}
|
||||
@@ -520,6 +551,7 @@ export class ClaudeCompactionEnforcer {
|
||||
const boundary = await sendAttempt(
|
||||
buildPostCompactTurnBoundaryPrompt(),
|
||||
{ waitForIdleMs: this.postCompactSendWaitMs },
|
||||
{ finalize: false },
|
||||
);
|
||||
if (!boundary.ok) {
|
||||
// Busy/never-idle → no delivery, no advance; the SAME stage retries next tick.
|
||||
@@ -543,6 +575,7 @@ export class ClaudeCompactionEnforcer {
|
||||
ignoredWrongSeatExtra: extra.ignoredWrongSeat,
|
||||
}),
|
||||
{ waitForIdleMs: this.postCompactSendWaitMs },
|
||||
{ finalize: false },
|
||||
);
|
||||
if (!restore.ok) {
|
||||
// Restore is exact-once + operator-authorized: if the seat is still busy
|
||||
@@ -567,6 +600,7 @@ export class ClaudeCompactionEnforcer {
|
||||
}
|
||||
this.pendingPostCompactRestore.delete(input.sessionName);
|
||||
this.pendingStageGeneration.delete(input.sessionName); // GHOST-STAGE (b): stage completed → drop its gen
|
||||
this.pendingStageAuthorization.delete(input.sessionName);
|
||||
this.postCompactRestoreCooldownUntil.set(
|
||||
input.sessionName,
|
||||
Date.now() + this.postCompactRestoreCooldownMs,
|
||||
@@ -578,6 +612,7 @@ export class ClaudeCompactionEnforcer {
|
||||
}
|
||||
this.triggeredAboveThreshold.delete(input.sessionName);
|
||||
this.pendingPreCompactPrep.delete(input.sessionName);
|
||||
this.pendingStageAuthorization.delete(input.sessionName);
|
||||
return { triggered: false, reason: "below_threshold" };
|
||||
}
|
||||
|
||||
@@ -623,7 +658,7 @@ export class ClaudeCompactionEnforcer {
|
||||
preCompactInstruction: policy.preCompactInstruction,
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
{ consume: false },
|
||||
);
|
||||
if (!prep.ok) {
|
||||
return prep.outcome;
|
||||
@@ -642,6 +677,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.triggeredAboveThreshold.add(input.sessionName);
|
||||
this.pendingPreCompactPrep.delete(input.sessionName);
|
||||
this.pendingPostCompactRestore.set(input.sessionName, "turn_boundary");
|
||||
this.pendingStageAuthorization.delete(input.sessionName);
|
||||
// GHOST-STAGE (b): capture the occupant generation at queue time (or null when unknown).
|
||||
this.pendingStageGeneration.set(input.sessionName, this.resolveOccupantGeneration?.(input.sessionName) ?? null);
|
||||
return attempt.outcome;
|
||||
@@ -772,6 +808,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.triggeredAboveThreshold.add(input.sessionName);
|
||||
this.pendingPreCompactPrep.delete(input.sessionName);
|
||||
this.pendingPostCompactRestore.set(input.sessionName, "turn_boundary");
|
||||
this.pendingStageAuthorization.delete(input.sessionName);
|
||||
// GHOST-STAGE (b): capture the occupant generation at queue time (or null when unknown).
|
||||
this.pendingStageGeneration.set(input.sessionName, this.resolveOccupantGeneration?.(input.sessionName) ?? null);
|
||||
this.setManualStage(input.sessionName, "compact-sent", undefined, opts.operatorInitiated === true);
|
||||
@@ -799,6 +836,7 @@ export class ClaudeCompactionEnforcer {
|
||||
this.pendingPostCompactRestore.delete(sessionName);
|
||||
this.manualCompactionState.delete(sessionName);
|
||||
this.pendingStageGeneration.delete(sessionName); // GHOST-STAGE (b): drop the captured queue-time gen
|
||||
this.pendingStageAuthorization.delete(sessionName);
|
||||
}
|
||||
|
||||
private setManualStage(sessionName: string, stage: ManualCompactionStage, reason?: string, operatorInitiated?: boolean): void {
|
||||
|
||||
@@ -584,6 +584,65 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
|
||||
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("carries one real stale_generation authorization through the inherited restore occurrence", async () => {
|
||||
const EnforcerDecisionStore = await loadDecisionStore();
|
||||
const db = makeDb();
|
||||
try {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const tx = transport();
|
||||
let generation = "gen-N";
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, {
|
||||
dedupWindowMs: 0,
|
||||
postCompactRestoreCooldownMs: 0,
|
||||
openrigHome: "/tmp/openrig-w4",
|
||||
resolveOccupantGeneration: () => generation,
|
||||
decisionStore: store,
|
||||
} as never);
|
||||
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
await enforcer.maybeAutoCompact(highInput());
|
||||
generation = "gen-N+1";
|
||||
const authorization = store.create(humanInput({
|
||||
generationUuid: generation,
|
||||
direction: "authorize",
|
||||
automaticReason: "stale_generation",
|
||||
reason: "allow the inherited restore occurrence once",
|
||||
}));
|
||||
const lifted = {
|
||||
triggered: true,
|
||||
decisionId: authorization["decisionId"],
|
||||
liftedReason: "stale_generation",
|
||||
};
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual(lifted);
|
||||
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual(lifted);
|
||||
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual(lifted);
|
||||
|
||||
expect(tx.send).toHaveBeenCalledTimes(5);
|
||||
expect(tx.send.mock.calls[2]?.[1]).toContain("post-compaction turn boundary");
|
||||
expect(tx.send.mock.calls[3]?.[1]).toContain("restoring this Claude session");
|
||||
expect(tx.send.mock.calls[4]?.[1]).toContain("audit your compaction restore");
|
||||
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
|
||||
decisionId: authorization["decisionId"],
|
||||
active: false,
|
||||
consumedByEnforcerKind: KIND,
|
||||
liftedReason: "stale_generation",
|
||||
attemptOutcome: "succeeded",
|
||||
}));
|
||||
|
||||
await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual({
|
||||
triggered: false,
|
||||
reason: "below_threshold",
|
||||
});
|
||||
expect(tx.send).toHaveBeenCalledTimes(5);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("consumes authorization on a failed compact attempt and records the actual failure", async () => {
|
||||
const send = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true as const })
|
||||
|
||||
Reference in new issue
Block a user