fix compaction enforcement decision boundaries

This commit is contained in:
v-openrig-build committed 2026-08-09 09:55:27 +00:00
1 parent 56858cbea5
commit d789fbe515
7 files changed
+298 -36

No files matched your search

@@ -99,7 +99,35 @@ export function compactionControlCommand(depsOverride?: CompactionControlDeps):
const response = await client.get(`/api/compaction/control${query}`, {
headers: terminalAuthHeaders(),
});
printResponse(response, options.json, "Compaction decisions listed.");
if (options.json || response.status >= 400) {
printResponse(response, options.json, "Compaction decisions listed.");
return;
}
const data = response.data as {
decisions?: Array<{
decisionId?: string;
sessionName?: string;
direction?: string;
active?: boolean;
lastObservedAt?: string | null;
lastObservedOutcome?: string | null;
}>;
};
const decisions = data.decisions ?? [];
if (decisions.length === 0) {
console.log("No compaction decisions.");
return;
}
for (const decision of decisions) {
console.log([
decision.decisionId ?? "unknown-id",
decision.sessionName ?? "unknown-session",
decision.direction ?? "unknown-direction",
decision.active === false ? "inactive" : "active",
`lastObservedAt=${decision.lastObservedAt ?? "never"}`,
`lastObservedOutcome=${decision.lastObservedOutcome ?? "none"}`,
].join(" "));
}
}),
);
@@ -143,6 +143,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
"policies.claude_compaction.message_inline",
"policies.claude_compaction.message_file_path",
"policies.claude_compaction.post_restore_audit_instruction",
"policies.claude_compaction.authorize_ttl_minutes",
// OPR.0.5.1 51-06 W2c — tunable watchdog auto-registration cadence.
"policies.idle_gate_qitem.scan_interval_seconds",
"policies.idle_gate_qitem.active_wake_interval_seconds",
@@ -149,6 +149,20 @@ describe("rig compaction-control", () => {
});
});
it("human list output exposes decision identity and durable observation fields", async () => {
const factory = await loadCommand();
const { deps } = makeDeps();
const command = factory(deps);
await command.parseAsync([
"node", "rig", "list", "--session", "claude-seat@rig",
]);
const rendered = logs.join("\n");
expect(rendered).toContain("hold-1");
expect(rendered).toContain("claude-seat@rig");
expect(rendered).toContain("2026-08-08T18:00:30.000Z");
expect(rendered).toContain("human_hold");
});
it("clear records a human reason through the decision-specific route", async () => {
const factory = await loadCommand();
const { calls, deps } = makeDeps();
@@ -102,7 +102,7 @@ export interface ManualCompactionStatus {
export type ManualCompactionOutcome =
| { triggered: true; stage: "compact-sent" }
| { triggered: false; stage: "skipped-or-failed"; reason: string };
| { triggered: false; stage: "skipped-or-failed"; reason: string; decisionId?: string };
export type EnforcerSkipReason =
| "runtime_filter"
@@ -372,16 +372,8 @@ export class ClaudeCompactionEnforcer {
}
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
const activeHold = this.decisionStore?.findActiveHold({
enforcerKind: "claude_compaction",
sessionName: input.sessionName,
liveGenerationUuid,
});
const activeHold = this.findAndObserveHold(input.sessionName, liveGenerationUuid);
if (activeHold) {
this.decisionStore?.observeHold({
decisionId: activeHold.decisionId,
outcome: "human_hold",
});
return {
triggered: false,
reason: "human_hold",
@@ -407,40 +399,49 @@ export class ClaudeCompactionEnforcer {
const sendAttempt = async (
message: string,
opts?: SendOpts,
consumeAuthorization = true,
): Promise<{ ok: boolean; result: SendResult | null; outcome: EnforcerOutcome }> => {
const lifted = authorization as {
decisionId: string;
liftedReason: AuthorizableCompactionReason;
} | null;
if (lifted && !this.decisionStore?.consumeAuthorizationForAttempt({
decisionId: lifted.decisionId,
enforcerKind: "claude_compaction",
liftedReason: lifted.liftedReason,
})) {
return {
ok: false,
result: null,
outcome: { triggered: false, reason: lifted.liftedReason },
};
let consumed = false;
if (lifted && consumeAuthorization) {
consumed = this.decisionStore?.consumeAuthorizationForAttempt({
decisionId: lifted.decisionId,
enforcerKind: "claude_compaction",
liftedReason: lifted.liftedReason,
}) ?? false;
if (!consumed) {
return {
ok: false,
result: null,
outcome: { triggered: false, reason: lifted.liftedReason },
};
}
}
let result: SendResult;
try {
result = await this.sessionTransport.send(input.sessionName, message, opts);
result = opts === undefined
? await this.sessionTransport.send(input.sessionName, message)
: await this.sessionTransport.send(input.sessionName, message, opts);
} catch (error) {
if (!lifted) throw error;
this.decisionStore?.recordAuthorizationAttempt({
decisionId: lifted.decisionId,
outcome: "failed",
failureReason: error instanceof Error ? error.message : String(error),
});
if (consumed) {
this.decisionStore?.recordAuthorizationAttempt({
decisionId: lifted.decisionId,
outcome: "failed",
failureReason: error instanceof Error ? error.message : String(error),
});
}
return {
ok: false,
result: null,
outcome: { triggered: false, reason: "send_failed" },
};
}
if (lifted) {
if (lifted && consumed) {
this.decisionStore?.recordAuthorizationAttempt({
decisionId: lifted.decisionId,
outcome: result.ok ? "succeeded" : "failed",
@@ -621,6 +622,8 @@ export class ClaudeCompactionEnforcer {
thresholdPercent: policy.thresholdPercent,
preCompactInstruction: policy.preCompactInstruction,
}),
undefined,
false,
);
if (!prep.ok) {
return prep.outcome;
@@ -667,6 +670,17 @@ export class ClaudeCompactionEnforcer {
input: EnforcerInput,
opts: { operatorInitiated?: boolean } = {},
): Promise<ManualCompactionOutcome> {
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
const activeHold = this.findAndObserveHold(input.sessionName, liveGenerationUuid);
if (activeHold) {
return {
triggered: false,
stage: "skipped-or-failed",
reason: "human_hold",
decisionId: activeHold.decisionId,
};
}
// OPR.0.4.3.14 rev1-r2 fix — SAME-SEAT IN-PROGRESS GUARD (race-safe), at the
// VERY TOP before ANY recordManualFailure path. This synchronous check-and-set
// runs BEFORE the first await; because JS is run-to-completion, two concurrent
@@ -796,6 +810,24 @@ export class ClaudeCompactionEnforcer {
return { triggered: false, stage: "skipped-or-failed", reason };
}
private findAndObserveHold(
sessionName: string,
liveGenerationUuid: string | null,
): EnforcerDecision | null {
const hold = this.decisionStore?.findActiveHold({
enforcerKind: "claude_compaction",
sessionName,
liveGenerationUuid,
}) ?? null;
if (hold) {
this.decisionStore?.observeHold({
decisionId: hold.decisionId,
outcome: "human_hold",
});
}
return hold;
}
/**
* Advance the surfaced manual stage monotonically, and ONLY when the current
* stage matches `from`. This makes the back-half updates a no-op for auto
+26
View File
@@ -63,6 +63,28 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
}
}
const currentSession = sessionRegistry.db.prepare(`
WITH target AS (
SELECT node_id
FROM sessions
WHERE session_name = ?
ORDER BY id DESC
LIMIT 1
)
SELECT session_name
FROM sessions
WHERE node_id = (SELECT node_id FROM target)
ORDER BY id DESC
LIMIT 1
`).get(sessionName) as { session_name: string } | undefined;
if (currentSession && currentSession.session_name !== sessionName) {
return c.json({
ok: false,
error: "session_not_current",
currentSession: currentSession.session_name,
}, 409);
}
const generationUuid = sessionRegistry.currentOccupantGenerationForSession(sessionName);
if (!generationUuid) {
return c.json({ ok: false, error: "generation_unavailable" }, 409);
@@ -199,6 +221,7 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
tmux_unavailable: 503,
send_failed: 502,
submit_failed: 502,
human_hold: 409,
};
const status = (statusMap[outcome.reason] ?? 409) as 400 | 404 | 409 | 422 | 502 | 503;
return c.json({
@@ -206,6 +229,7 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
session: sessionName,
stage: outcome.stage,
reason: outcome.reason,
...(outcome.decisionId ? { decisionId: outcome.decisionId } : {}),
error: manualReasonMessage(sessionName, outcome.reason),
}, status);
});
@@ -239,6 +263,8 @@ function manualReasonMessage(sessionName: string, reason: string): string {
return `Refused: '${sessionName}' activity could not be determined; failing closed so /compact cannot land on a prompt.`;
case "wait_for_idle_timeout":
return `Prep was sent to '${sessionName}' but it did not go idle in time, so /compact was NOT sent. Retry once the prep turn completes.`;
case "human_hold":
return `Refused: '${sessionName}' has an active human compaction hold.`;
default:
return `Manual compaction for '${sessionName}' did not complete (${reason}).`;
}
@@ -6,6 +6,8 @@ import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
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 { compactionRoutes } from "../src/routes/compaction.js";
const IDENTITY = "orch-lead@rig";
@@ -14,6 +16,7 @@ const SEAT = "claude-seat@rig";
describe("W4 compaction-control routes", () => {
let db: Database.Database;
let sessionRegistry: SessionRegistry;
let nodeId: string;
let generationUuid: string;
let decisions: {
create: ReturnType<typeof vi.fn>;
@@ -21,6 +24,11 @@ describe("W4 compaction-control routes", () => {
clear: ReturnType<typeof vi.fn>;
};
let app: Hono;
let manualDeps: {
enforcer: ClaudeCompactionEnforcer;
transport: Record<string, unknown>;
usageStore: Record<string, unknown>;
} | null;
beforeEach(() => {
db = createDb();
@@ -29,6 +37,7 @@ describe("W4 compaction-control routes", () => {
sessionRegistry = new SessionRegistry(db);
const rig = rigs.createRig("rig");
const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" });
nodeId = node.id;
sessionRegistry.registerSession(node.id, SEAT);
generationUuid = sessionRegistry.currentOccupantGenerationForSession(SEAT)!;
decisions = {
@@ -43,11 +52,17 @@ describe("W4 compaction-control routes", () => {
}]),
clear: vi.fn((input) => ({ decisionId: input.decisionId, active: false, releaseKind: "cleared" })),
};
manualDeps = null;
app = new Hono();
app.use("*", async (c, next) => {
c.set("db" as never, db);
c.set("sessionRegistry" as never, sessionRegistry);
c.set("enforcerDecisionStore" as never, decisions);
if (manualDeps) {
c.set("compactionEnforcer" as never, manualDeps.enforcer);
c.set("sessionTransport" as never, manualDeps.transport);
c.set("contextUsageStore" as never, manualDeps.usageStore);
}
await next();
});
app.route("/api/compaction", compactionRoutes());
@@ -170,6 +185,106 @@ describe("W4 compaction-control routes", () => {
expect(decisions.create).not.toHaveBeenCalled();
});
it.each(["hold", "authorize"] as const)(
"rejects historical session aliases for %s and accepts only the current alias",
async (direction) => {
const currentSeat = "claude-seat-next@rig";
sessionRegistry.registerSession(nodeId, currentSeat);
const automaticReason = direction === "authorize" ? "disabled" : undefined;
const body = {
session: SEAT,
direction,
...(automaticReason ? { automaticReason } : {}),
reason: `current-generation ${direction}`,
};
const stale = await request("/api/compaction/control", body);
expect(stale.status).toBe(409);
expect(await stale.json()).toMatchObject({
error: "session_not_current",
currentSession: currentSeat,
});
expect(decisions.create).not.toHaveBeenCalled();
const current = await request("/api/compaction/control", { ...body, session: currentSeat });
expect(current.status).toBe(201);
expect(decisions.create).toHaveBeenCalledWith(expect.objectContaining({
sessionName: currentSeat,
generationUuid: sessionRegistry.currentOccupantGenerationForSession(currentSeat),
direction,
}));
},
);
it("the public manual trigger honors a current hold, records observation, and sends nothing", async () => {
const store = new EnforcerDecisionStore(db, {
now: () => new Date("2026-08-08T18:00:00.000Z"),
authorizeTtlMinutes: () => 15,
});
const hold = store.create({
enforcerKind: "claude_compaction",
sessionName: SEAT,
generationUuid,
direction: "hold",
automaticReason: null,
reason: "finish the current atomic action",
actorSession: IDENTITY,
identityProvenance: "transport:v1",
});
const send = vi.fn(async () => ({ ok: true as const }));
const transport = {
send,
resolveSessions: vi.fn(async () => ({ ok: true as const, sessions: [SEAT] })),
};
const enforcer = new ClaudeCompactionEnforcer(
{
resolveClaudeCompactionPolicy: () => ({
enabled: true,
thresholdPercent: 80,
preCompactInstruction: "prepare",
compactInstruction: "",
messageInline: "",
messageFilePath: "",
postRestoreAuditInstruction: "audit",
authorizeTtlMinutes: 15,
}),
} as never,
transport as never,
{
resolveOccupantGeneration: (sessionName: string) =>
sessionRegistry.currentOccupantGenerationForSession(sessionName),
decisionStore: store,
},
);
manualDeps = {
enforcer,
transport,
usageStore: {
getForNode: () => ({
availability: "known",
usedPercentage: 90,
transcriptPath: "/tmp/claude.jsonl",
sessionId: "claude-session",
}),
},
};
decisions = store as never;
const response = await request("/api/compaction/trigger", { session: SEAT });
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({
ok: false,
reason: "human_hold",
decisionId: hold.decisionId,
});
expect(send).not.toHaveBeenCalled();
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
decisionId: hold.decisionId,
lastObservedOutcome: "human_hold",
lastObservedAt: "2026-08-08T18:00:00.000Z",
}));
});
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);
@@ -440,6 +440,16 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
expect(tx.send).not.toHaveBeenCalled();
liveGeneration = "gen-N";
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: authorization["decisionId"],
liftedReason: "disabled",
});
expect(row()).toMatchObject({ active: true });
expect(row()?.["consumedAt"] ?? null).toBeNull();
expect(tx.send).toHaveBeenCalledTimes(1);
expect(tx.send.mock.calls[0]?.[1]).toContain("automatic compaction preparation");
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: authorization["decisionId"],
@@ -451,13 +461,14 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
liftedReason: "disabled",
attemptOutcome: "succeeded",
});
expect(tx.send).toHaveBeenCalledTimes(1);
expect(tx.send).toHaveBeenCalledTimes(2);
expect(tx.send.mock.calls[1]?.[1]).toMatch(/^\/compact/);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "disabled",
});
expect(tx.send).toHaveBeenCalledTimes(1);
expect(tx.send).toHaveBeenCalledTimes(2);
} finally {
db.close();
}
@@ -481,6 +492,14 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
liftedReason: "disabled",
});
expect(tx.send).toHaveBeenCalledTimes(1);
expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled();
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "disabled",
});
expect(tx.send).toHaveBeenCalledTimes(2);
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({
decisionId: "authorize-1",
@@ -528,6 +547,13 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
decisionId: "authorize-1",
liftedReason: "post_restore_cooldown",
});
expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled();
now += 1_000;
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "post_restore_cooldown",
});
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
} finally {
vi.restoreAllMocks();
@@ -558,8 +584,10 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1);
});
it("consumes authorization on a failed attempt and records the actual failure", async () => {
const send = vi.fn(async () => ({ ok: false as const, reason: "send_failed" }));
it("consumes authorization on a failed compact attempt and records the actual failure", async () => {
const send = vi.fn()
.mockResolvedValueOnce({ ok: true as const })
.mockResolvedValueOnce({ ok: false as const, reason: "send_failed" });
const decisions = decisionHarness({ authorizationReason: "disabled" });
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
@@ -570,6 +598,13 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: "authorize-1",
liftedReason: "disabled",
});
expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled();
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "send_failed",
@@ -596,9 +631,9 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
automaticReason: "disabled",
reason: "allow one attempted send",
}));
const send = vi.fn(async () => {
throw new Error("transport exploded");
});
const send = vi.fn()
.mockResolvedValueOnce({ ok: true as const })
.mockRejectedValueOnce(new Error("transport exploded"));
const enforcer = new ClaudeCompactionEnforcer(
settings({ ...BASE_POLICY, enabled: false }),
{ send } as unknown as SessionTransport,
@@ -608,6 +643,17 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
} as never,
);
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: true,
decisionId: authorization["decisionId"],
liftedReason: "disabled",
});
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
decisionId: authorization["decisionId"],
active: true,
consumedAt: null,
}));
await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({
triggered: false,
reason: "send_failed",
@@ -625,7 +671,7 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
triggered: false,
reason: "disabled",
});
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledTimes(2);
} finally {
db.close();
}