diff --git a/packages/cli/src/commands/compaction-control.ts b/packages/cli/src/commands/compaction-control.ts new file mode 100644 index 000000000..01ecc4fd5 --- /dev/null +++ b/packages/cli/src/commands/compaction-control.ts @@ -0,0 +1,124 @@ +import { Command } from "commander"; +import { DaemonClient, terminalAuthHeaders } from "../client.js"; +import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js"; +import { realDeps } from "./daemon.js"; + +export interface CompactionControlDeps { + lifecycleDeps: LifecycleDeps; + clientFactory: (url: string) => Pick; +} + +function defaultDeps(): CompactionControlDeps { + return { + lifecycleDeps: realDeps(), + clientFactory: (url: string) => new DaemonClient(url), + }; +} + +async function getClient(deps: CompactionControlDeps) { + const status = await getDaemonStatus(deps.lifecycleDeps); + if (status.state !== "running" || status.healthy === false) { + console.error("Daemon is not running. Start it with: rig daemon start"); + process.exitCode = 1; + return null; + } + return deps.clientFactory(getDaemonUrl(status)); +} + +function printResponse( + response: { status: number; data: unknown }, + json: boolean | undefined, + success: string, +): void { + if (json) { + console.log(JSON.stringify(response.data)); + } else if (response.status < 400) { + console.log(success); + } else { + const data = response.data as { error?: string; message?: string }; + console.error(data.message ?? data.error ?? `Request failed (HTTP ${response.status})`); + } + if (response.status >= 400) process.exitCode = response.status >= 500 ? 2 : 1; +} + +export function compactionControlCommand(depsOverride?: CompactionControlDeps): Command { + const command = new Command("compaction-control") + .description("Create, inspect, and clear generation-scoped Claude compaction decisions"); + const deps = () => depsOverride ?? defaultDeps(); + + command.addCommand( + new Command("hold") + .argument("", "Target Claude session") + .requiredOption("--reason ", "Why automatic compaction must not act") + .option("--json", "JSON output for agents") + .action(async (session: string, options: { reason: string; json?: boolean }) => { + const client = await getClient(deps()); + if (!client) return; + const response = await client.post("/api/compaction/control", { + session, + direction: "hold", + reason: options.reason, + }, { headers: terminalAuthHeaders() }); + printResponse(response, options.json, `Compaction hold created for ${session}.`); + }), + ); + + command.addCommand( + new Command("authorize") + .argument("", "Target Claude session") + .requiredOption( + "--automatic-reason ", + "One refusal to lift: disabled, post_restore_cooldown, or stale_generation", + ) + .requiredOption("--reason ", "Why this one attempt is authorized") + .option("--json", "JSON output for agents") + .action(async ( + session: string, + options: { automaticReason: string; reason: string; json?: boolean }, + ) => { + const client = await getClient(deps()); + if (!client) return; + const response = await client.post("/api/compaction/control", { + session, + direction: "authorize", + automaticReason: options.automaticReason, + reason: options.reason, + }, { headers: terminalAuthHeaders() }); + printResponse(response, options.json, `One compaction attempt authorized for ${session}.`); + }), + ); + + command.addCommand( + new Command("list") + .option("--session ", "Limit decisions to one session") + .option("--json", "JSON output for agents") + .action(async (options: { session?: string; json?: boolean }) => { + const client = await getClient(deps()); + if (!client) return; + const query = options.session ? `?session=${encodeURIComponent(options.session)}` : ""; + const response = await client.get(`/api/compaction/control${query}`, { + headers: terminalAuthHeaders(), + }); + printResponse(response, options.json, "Compaction decisions listed."); + }), + ); + + command.addCommand( + new Command("clear") + .argument("", "Decision ID") + .requiredOption("--reason ", "Why the decision is being cleared") + .option("--json", "JSON output for agents") + .action(async (decisionId: string, options: { reason: string; json?: boolean }) => { + const client = await getClient(deps()); + if (!client) return; + const response = await client.post( + `/api/compaction/control/${encodeURIComponent(decisionId)}/clear`, + { reason: options.reason }, + { headers: terminalAuthHeaders() }, + ); + printResponse(response, options.json, `Compaction decision ${decisionId} cleared.`); + }), + ); + + return command; +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index db720ba3d..6ec2c117c 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -72,7 +72,8 @@ Keys: workflow.* exception_routing (orchestrator | human_only — the maturity-dial host default) policies.claude_compaction.* enabled, threshold_percent, compact_instruction, - message_inline, message_file_path + message_inline, message_file_path, + authorize_ttl_minutes policies.idle_gate_qitem.scan_interval_seconds policies.idle_gate_qitem.active_wake_interval_seconds snapshots.periodic.* enabled, interval_seconds, retention_keep diff --git a/packages/cli/src/config-store.ts b/packages/cli/src/config-store.ts index 13da93a65..67af289f8 100644 --- a/packages/cli/src/config-store.ts +++ b/packages/cli/src/config-store.ts @@ -116,6 +116,7 @@ export interface RiggedConfig { messageInline: string; messageFilePath: string; postRestoreAuditInstruction: string; + authorizeTtlMinutes: number; }; idleGateQitem: { scanIntervalSeconds: number; @@ -233,6 +234,7 @@ const DEFAULTS = { messageInline: DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION, messageFilePath: DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH, postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION, + authorizeTtlMinutes: 15, }, idleGateQitem: { scanIntervalSeconds: 60, @@ -326,6 +328,7 @@ export const VALID_KEYS = [ "policies.claude_compaction.message_inline", "policies.claude_compaction.message_file_path", "policies.claude_compaction.post_restore_audit_instruction", + "policies.claude_compaction.authorize_ttl_minutes", "policies.idle_gate_qitem.scan_interval_seconds", "policies.idle_gate_qitem.active_wake_interval_seconds", "snapshots.periodic.enabled", @@ -397,6 +400,7 @@ export const ENV_MAP: Record = { "policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" }, "policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" }, "policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" }, + "policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" }, "policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" }, "policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" }, "snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" }, @@ -456,6 +460,7 @@ const KEY_TO_PATH: Record = { "policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"], "policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"], "policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"], + "policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"], "policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"], "policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"], "snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"], @@ -635,6 +640,19 @@ const KEY_CONSTRAINTS: Partial { + const trimmed = (raw ?? "").trim(); + if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) { + throw new Error( + `Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`, + ); + } + if (coerced < 1 || coerced > 60) { + throw new Error( + `Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`, + ); + } + }, "snapshots.periodic.interval_seconds": (raw, coerced) => { if (!/^-?\d+$/.test((raw ?? "").trim())) { throw new Error( @@ -839,6 +857,7 @@ export class ConfigStore { messageInline: v("policies.claude_compaction.message_inline") as string, messageFilePath: v("policies.claude_compaction.message_file_path") as string, postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string, + authorizeTtlMinutes: v("policies.claude_compaction.authorize_ttl_minutes") as number, }, idleGateQitem: { scanIntervalSeconds: v("policies.idle_gate_qitem.scan_interval_seconds") as number, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b51c51d06..76b3f93de 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -77,6 +77,10 @@ import { restoreCheckCommand } from "./commands/restore-check.js"; import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore-packet.js"; import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js"; import { compactCommand, type CompactDeps } from "./commands/compact.js"; +import { + compactionControlCommand, + type CompactionControlDeps, +} from "./commands/compaction-control.js"; import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js"; import { seatCommand, handoverCommand, type SeatDeps } from "./commands/seat.js"; import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js"; @@ -144,6 +148,7 @@ export interface ProgramDeps { restorePacketDeps?: RestorePacketDeps; compactPlanDeps?: CompactPlanDeps; compactDeps?: CompactDeps; + compactionControlDeps?: CompactionControlDeps; heartbeatDeps?: HeartbeatDeps; seatDeps?: SeatDeps; rigPolicyDeps?: RigPolicyDeps; @@ -235,6 +240,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command { program.addCommand(restorePacketCommand(depsOverride?.restorePacketDeps)); program.addCommand(compactPlanCommand(depsOverride?.compactPlanDeps)); program.addCommand(compactCommand(depsOverride?.compactDeps)); + program.addCommand(compactionControlCommand(depsOverride?.compactionControlDeps)); program.addCommand(heartbeatCommand(depsOverride?.heartbeatDeps)); program.addCommand(seatCommand(depsOverride?.seatDeps)); program.addCommand(handoverCommand(depsOverride?.seatDeps)); diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index 3d392aadc..36a80c2d7 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -6,6 +6,7 @@ import http from "node:http"; import { Command } from "commander"; import { ConfigStore, VALID_KEYS } from "../src/config-store.js"; import { configCommand } from "../src/commands/config.js"; +import { SETTINGS_VALID_KEYS } from "../../daemon/src/domain/user-settings/settings-store.js"; import { DaemonClient } from "../src/client.js"; import { STATE_FILE, type DaemonState } from "../src/daemon-lifecycle.js"; import type { StatusDeps } from "../src/commands/status.js"; @@ -426,3 +427,114 @@ describe("Config CLI", () => { expect(combined).toContain("policies.idle_gate_qitem.active_wake_interval_seconds"); }); }); + +describe("W4 compaction authorization TTL config", () => { + const key = "policies.claude_compaction.authorize_ttl_minutes"; + const envKey = "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"; + let tmpDir: string; + let configPath: string; + let savedEnv: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "w4-config-test-")); + configPath = join(tmpDir, "config.json"); + savedEnv = process.env[envKey]; + delete process.env[envKey]; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (savedEnv === undefined) delete process.env[envKey]; + else process.env[envKey] = savedEnv; + }); + + it("keeps the daemon and CLI compaction key registries exactly aligned", () => { + const compactionKeys = (keys: readonly string[]) => + keys.filter((candidate) => candidate.startsWith("policies.claude_compaction.")); + expect(compactionKeys(VALID_KEYS)).toEqual(compactionKeys(SETTINGS_VALID_KEYS)); + expect(VALID_KEYS).toContain(key); + }); + + it("defaults to 15 and exposes the typed policy field", () => { + const store = new ConfigStore(configPath); + expect(store.resolveWithSource(key)).toEqual({ + value: 15, + source: "default", + defaultValue: 15, + }); + expect(store.resolve().policies.claudeCompaction.authorizeTtlMinutes).toBe(15); + }); + + it("accepts a valid environment override", () => { + process.env[envKey] = "30"; + expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({ + value: 30, + source: "env", + defaultValue: 15, + }); + }); + + it.each(["0", "61", "15.5", "bad"])( + "invalid env %s falls back to 15 and reports loudly", + (raw) => { + process.env[envKey] = raw; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({ + value: 15, + source: "default", + defaultValue: 15, + }); + expect(stderr.mock.calls.some((call) => String(call[0]).includes( + `env override for ${key} rejected`, + ))).toBe(true); + } finally { + stderr.mockRestore(); + } + }, + ); + + it.each([0, 61, 15.5, "bad"])( + "invalid file value %s falls back to 15 and reports loudly", + (raw) => { + writeFileSync(configPath, JSON.stringify({ + policies: { claudeCompaction: { authorizeTtlMinutes: raw } }, + })); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + expect(new ConfigStore(configPath).resolveWithSource(key)).toEqual({ + value: 15, + source: "default", + defaultValue: 15, + }); + expect(stderr.mock.calls.some((call) => String(call[0]).includes( + `file value for ${key} rejected`, + ))).toBe(true); + } finally { + stderr.mockRestore(); + } + }, + ); + + it("accepts exactly 1..60 through set and rejects values outside the range", () => { + const store = new ConfigStore(configPath); + for (const value of ["1", "15", "60"]) { + store.set(key, value); + expect(store.get(key)).toBe(Number(value)); + } + for (const value of ["0", "61", "15.5", "bad"]) { + expect(() => store.set(key, value)).toThrow(/integer|\[1, 60\]|number/i); + } + }); + + it("prints the literal key in rig config help", () => { + const command = configCommand(configPath); + let output = ""; + command.configureOutput({ + writeOut: (text) => { output += text; }, + writeErr: () => {}, + }); + command.outputHelp(); + expect(output).toContain(key); + }); +}); diff --git a/packages/cli/test/w4-compaction-control.test.ts b/packages/cli/test/w4-compaction-control.test.ts new file mode 100644 index 000000000..64dde3a63 --- /dev/null +++ b/packages/cli/test/w4-compaction-control.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Command } from "commander"; +import { createProgram } from "../src/index.js"; + +const COMMAND_MODULE = ["../src/commands", "compaction-control.js"].join("/"); + +vi.mock("../src/daemon-lifecycle.js", async () => { + const actual = await vi.importActual>("../src/daemon-lifecycle.js"); + return { + ...actual, + getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1234, port: 7433 })), + getDaemonUrl: vi.fn(() => "http://localhost:7433"), + }; +}); + +interface TestDeps { + lifecycleDeps: Record; + clientFactory: () => { + get(path: string): Promise<{ status: number; data: unknown }>; + post(path: string, body: unknown): Promise<{ status: number; data: unknown }>; + }; +} + +type CommandFactory = (deps: TestDeps) => Command; + +async function loadCommand(): Promise { + let loaded: { compactionControlCommand?: CommandFactory } | null = null; + try { + loaded = await import(/* @vite-ignore */ COMMAND_MODULE); + } catch { + // Assertion below keeps the RED feature-shaped instead of surfacing a loader error. + } + expect(loaded, "compaction-control command module must exist").not.toBeNull(); + expect(loaded?.compactionControlCommand).toBeTypeOf("function"); + return loaded!.compactionControlCommand!; +} + +function makeDeps() { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + const deps: TestDeps = { + lifecycleDeps: {}, + clientFactory: () => ({ + get: vi.fn(async (path: string) => { + calls.push({ method: "GET", path }); + return { + status: 200, + data: { + ok: true, + decisions: [{ + decisionId: "hold-1", + sessionName: "claude-seat@rig", + direction: "hold", + lastObservedAt: "2026-08-08T18:00:30.000Z", + lastObservedOutcome: "human_hold", + }], + }, + }; + }), + post: vi.fn(async (path: string, body: unknown) => { + calls.push({ method: "POST", path, body }); + return { status: path.endsWith("/clear") ? 200 : 201, data: { ok: true, decisionId: "decision-1" } }; + }), + }), + }; + return { calls, deps }; +} + +describe("rig compaction-control", () => { + let logs: string[]; + + beforeEach(() => { + logs = []; + vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.join(" "))); + vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + }); + + it("is registered as one grouped command with hold, authorize, list, and clear", () => { + const program = createProgram({ compactionControlDeps: makeDeps().deps } as never); + const command = program.commands.find((candidate) => candidate.name() === "compaction-control"); + expect(command).toBeDefined(); + expect(command!.commands.map((child) => child.name()).sort()).toEqual([ + "authorize", + "clear", + "hold", + "list", + ]); + }); + + it("hold posts the target and required human reason without a body actor", async () => { + const factory = await loadCommand(); + const { calls, deps } = makeDeps(); + const command = factory(deps); + await command.parseAsync([ + "node", "rig", "hold", "claude-seat@rig", + "--reason", "finish the atomic action", + "--json", + ]); + expect(calls).toEqual([{ + method: "POST", + path: "/api/compaction/control", + body: { + session: "claude-seat@rig", + direction: "hold", + reason: "finish the atomic action", + }, + }]); + }); + + it("authorize posts exactly one named lift and no caller-controlled expiry", async () => { + const factory = await loadCommand(); + const { calls, deps } = makeDeps(); + const command = factory(deps); + await command.parseAsync([ + "node", "rig", "authorize", "claude-seat@rig", + "--automatic-reason", "disabled", + "--reason", "allow one attempt", + "--json", + ]); + expect(calls).toEqual([{ + method: "POST", + path: "/api/compaction/control", + body: { + session: "claude-seat@rig", + direction: "authorize", + automaticReason: "disabled", + reason: "allow one attempt", + }, + }]); + }); + + it("list uses the scoped read route and renders durable hold observation", async () => { + const factory = await loadCommand(); + const { calls, deps } = makeDeps(); + const command = factory(deps); + await command.parseAsync([ + "node", "rig", "list", "--session", "claude-seat@rig", "--json", + ]); + expect(calls).toEqual([{ + method: "GET", + path: "/api/compaction/control?session=claude-seat%40rig", + }]); + expect(JSON.parse(logs.join(""))).toMatchObject({ + decisions: [{ + decisionId: "hold-1", + lastObservedAt: "2026-08-08T18:00:30.000Z", + lastObservedOutcome: "human_hold", + }], + }); + }); + + it("clear records a human reason through the decision-specific route", async () => { + const factory = await loadCommand(); + const { calls, deps } = makeDeps(); + const command = factory(deps); + await command.parseAsync([ + "node", "rig", "clear", "hold-1", "--reason", "atomic action completed", "--json", + ]); + expect(calls).toEqual([{ + method: "POST", + path: "/api/compaction/control/hold-1/clear", + body: { reason: "atomic action completed" }, + }]); + }); +}); diff --git a/packages/daemon/src/db/all-migrations.ts b/packages/daemon/src/db/all-migrations.ts index e82ab6d1f..013089c73 100644 --- a/packages/daemon/src/db/all-migrations.ts +++ b/packages/daemon/src/db/all-migrations.ts @@ -70,6 +70,7 @@ import { projectionManifestSchema } from "./migrations/064_projection_manifest.j import { identityProvenanceSchema } from "./migrations/065_identity_provenance.js"; import { watchdogTargetGenerationSchema } from "./migrations/066_watchdog_target_generation.js"; import { i3IdentityProvenanceSchema } from "./migrations/067_i3_identity_provenance.js"; +import { enforcerDecisionsSchema } from "./migrations/068_enforcer_decisions.js"; import type { Migration } from "./migrate.js"; /** Ordered 001→067 (064/066 from main, 065 + 067 the P21 identity_provenance era-stamps — all additive/independent). */ @@ -141,4 +142,5 @@ export const ALL_MIGRATIONS: Migration[] = [ identityProvenanceSchema, watchdogTargetGenerationSchema, i3IdentityProvenanceSchema, + enforcerDecisionsSchema, ]; diff --git a/packages/daemon/src/db/migrations/068_enforcer_decisions.ts b/packages/daemon/src/db/migrations/068_enforcer_decisions.ts new file mode 100644 index 000000000..375c6a4bb --- /dev/null +++ b/packages/daemon/src/db/migrations/068_enforcer_decisions.ts @@ -0,0 +1,41 @@ +import type { Migration } from "../migrate.js"; + +/** W4 — durable, attributable human hold/authorize decisions for enforcers. */ +export const enforcerDecisionsSchema: Migration = { + name: "068_enforcer_decisions.sql", + sql: ` + CREATE TABLE enforcer_decisions ( + decision_id TEXT PRIMARY KEY, + enforcer_kind TEXT NOT NULL, + session_name TEXT NOT NULL, + generation_uuid TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('hold', 'authorize')), + automatic_reason TEXT, + reason TEXT NOT NULL, + actor_session TEXT NOT NULL, + identity_provenance TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + release_kind TEXT, + released_at TEXT, + released_by_session TEXT, + release_identity_provenance TEXT, + release_reason TEXT, + consumed_at TEXT, + consumed_by_enforcer_kind TEXT, + lifted_reason TEXT, + attempt_outcome TEXT, + attempt_failure_reason TEXT, + last_observed_at TEXT, + last_observed_outcome TEXT + ); + + CREATE UNIQUE INDEX idx_enforcer_decisions_one_active + ON enforcer_decisions(enforcer_kind, session_name, generation_uuid) + WHERE active = 1; + + CREATE INDEX idx_enforcer_decisions_lookup + ON enforcer_decisions(enforcer_kind, session_name, direction, active, generation_uuid); + `, +}; diff --git a/packages/daemon/src/domain/claude-compaction-enforcer.ts b/packages/daemon/src/domain/claude-compaction-enforcer.ts index 0dc2e877c..51eef0b55 100644 --- a/packages/daemon/src/domain/claude-compaction-enforcer.ts +++ b/packages/daemon/src/domain/claude-compaction-enforcer.ts @@ -1,5 +1,10 @@ -import type { SessionTransport } from "./session-transport.js"; +import type { SendOpts, SendResult, SessionTransport } from "./session-transport.js"; import type { SettingsStore } from "./user-settings/settings-store.js"; +import type { + AuthorizableCompactionReason, + EnforcerDecision, + EnforcerDecisionStore, +} from "./enforcer-decision-store.js"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -68,8 +73,8 @@ export interface EnforcerInput { } export type EnforcerOutcome = - | { triggered: true } - | { triggered: false; reason: EnforcerSkipReason }; + | { triggered: true; decisionId?: string; liftedReason?: AuthorizableCompactionReason } + | { triggered: false; reason: EnforcerSkipReason; decisionId?: string }; /** * OPR.0.4.3.14 — manual compaction trigger surfaced stages (AC-3). `preparing` @@ -109,7 +114,8 @@ export type EnforcerSkipReason = | "post_restore_cooldown" | "send_failed" | "invalid_policy" - | "stale_generation"; + | "stale_generation" + | "human_hold"; function buildCompactCommand(compactInstruction: string): string { const normalized = compactInstruction.trim().replace(/\s+/g, " "); @@ -312,11 +318,34 @@ export class ClaudeCompactionEnforcer { // retired-generation stage → refuse. Injected resolver (atom-B's currentOccupantTenure by session). private readonly pendingStageGeneration = new Map(); private readonly resolveOccupantGeneration?: (sessionName: string) => string | null; + private readonly decisionStore?: Pick< + EnforcerDecisionStore, + | "findActiveHold" + | "findMatchingAuthorization" + | "observeHold" + | "consumeAuthorizationForAttempt" + | "recordAuthorizationAttempt" + >; constructor( settingsStore: SettingsStore, sessionTransport: SessionTransport, - opts?: { dedupWindowMs?: number; openrigHome?: string; postCompactRestoreCooldownMs?: number; manualPrepWaitMs?: number; postCompactSendWaitMs?: number; resolveOccupantGeneration?: (sessionName: string) => string | null }, + opts?: { + dedupWindowMs?: number; + openrigHome?: string; + postCompactRestoreCooldownMs?: number; + manualPrepWaitMs?: number; + postCompactSendWaitMs?: number; + resolveOccupantGeneration?: (sessionName: string) => string | null; + decisionStore?: Pick< + EnforcerDecisionStore, + | "findActiveHold" + | "findMatchingAuthorization" + | "observeHold" + | "consumeAuthorizationForAttempt" + | "recordAuthorizationAttempt" + >; + }, ) { this.settingsStore = settingsStore; this.sessionTransport = sessionTransport; @@ -326,6 +355,7 @@ export class ClaudeCompactionEnforcer { this.manualPrepWaitMs = opts?.manualPrepWaitMs ?? MANUAL_PREP_WAIT_MS_DEFAULT; this.postCompactSendWaitMs = opts?.postCompactSendWaitMs ?? POST_COMPACT_SEND_WAIT_MS_DEFAULT; this.resolveOccupantGeneration = opts?.resolveOccupantGeneration; + this.decisionStore = opts?.decisionStore; } /** @@ -341,6 +371,102 @@ export class ClaudeCompactionEnforcer { return { triggered: false, reason: "no_usage_data" }; } + const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null; + const activeHold = this.decisionStore?.findActiveHold({ + enforcerKind: "claude_compaction", + sessionName: input.sessionName, + liveGenerationUuid, + }); + if (activeHold) { + this.decisionStore?.observeHold({ + decisionId: activeHold.decisionId, + outcome: "human_hold", + }); + return { + triggered: false, + reason: "human_hold", + decisionId: activeHold.decisionId, + }; + } + + let authorization: { + decisionId: string; + liftedReason: AuthorizableCompactionReason; + } | null = null; + const lift = (automaticReason: AuthorizableCompactionReason): boolean => { + const found = this.decisionStore?.findMatchingAuthorization({ + enforcerKind: "claude_compaction", + sessionName: input.sessionName, + liveGenerationUuid, + automaticReason, + }) as EnforcerDecision | null | undefined; + if (!found) return false; + authorization = { decisionId: found.decisionId, liftedReason: automaticReason }; + return true; + }; + const sendAttempt = async ( + message: string, + opts?: SendOpts, + ): 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 result: SendResult; + try { + result = 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), + }); + return { + ok: false, + result: null, + outcome: { triggered: false, reason: "send_failed" }, + }; + } + if (lifted) { + this.decisionStore?.recordAuthorizationAttempt({ + decisionId: lifted.decisionId, + outcome: result.ok ? "succeeded" : "failed", + ...(result.ok ? {} : { failureReason: result.reason ?? result.error ?? "send_failed" }), + }); + } + if (!result.ok) { + return { + ok: false, + result, + outcome: { triggered: false, reason: "send_failed" }, + }; + } + return { + ok: true, + result, + outcome: lifted + ? { + triggered: true, + decisionId: lifted.decisionId, + liftedReason: lifted.liftedReason, + } + : { triggered: true }, + }; + }; + const policy = this.settingsStore.resolveClaudeCompactionPolicy(); // Defense in depth: the CLI + daemon set() paths reject invalid // threshold values, but a hand-edited ~/.openrig/config.json could @@ -371,7 +497,7 @@ export class ClaudeCompactionEnforcer { // generations residue is covered by fix (b)'s generation gate (layered defense). Interpretation // surfaced in the handoff for the PM evidence read (veto there if the literal reading was meant). if (!policy.enabled && this.manualCompactionState.get(input.sessionName)?.operatorInitiated !== true) { - return { triggered: false, reason: "disabled" }; + if (!lift("disabled")) return { triggered: false, reason: "disabled" }; } // GHOST-STAGE (b): gen-scoped stages. A stage minted by a RETIRED occupant generation must be // undeliverable to the successor. Compare the queue-time generation to the LIVE one. NOTE-2: an @@ -382,30 +508,30 @@ export class ClaudeCompactionEnforcer { if (stageGen != null) { const liveGen = this.resolveOccupantGeneration?.(input.sessionName) ?? null; if (liveGen != null && liveGen !== stageGen) { - this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage - return { triggered: false, reason: "stale_generation" }; + if (!lift("stale_generation")) { + this.invalidateOccupant(input.sessionName); // drop the retired-generation ghost stage + return { triggered: false, reason: "stale_generation" }; + } } } const pendingStage = this.pendingPostCompactRestore.get(input.sessionName); if (pendingStage === "turn_boundary") { - const boundary = await this.sessionTransport.send( - input.sessionName, + const boundary = await sendAttempt( buildPostCompactTurnBoundaryPrompt(), { waitForIdleMs: this.postCompactSendWaitMs }, ); if (!boundary.ok) { // Busy/never-idle → no delivery, no advance; the SAME stage retries next tick. - return { triggered: false, reason: "send_failed" }; + return boundary.outcome; } this.pendingPostCompactRestore.set(input.sessionName, "restore_prompt"); - return { triggered: true }; + return boundary.outcome; } if (pendingStage === "restore_prompt") { // OPR.0.4.1.09: resolve the extra FOR THIS SEAT (per-seat preferred; the legacy // global is refused if it declares a different seat) - never inject wrong-seat state. const extra = resolvePostCompactExtra(input.sessionName, this.openrigHome, policy.messageFilePath); - const restore = await this.sessionTransport.send( - input.sessionName, + const restore = await sendAttempt( buildPostCompactRestorePrompt({ sessionName: input.sessionName, openrigHome: this.openrigHome, @@ -421,23 +547,22 @@ export class ClaudeCompactionEnforcer { // Restore is exact-once + operator-authorized: if the seat is still busy // (mid-compaction/boundary), do NOT advance to restore-sent on an // undelivered send — retry the SAME stage next tick. - return { triggered: false, reason: "send_failed" }; + return restore.outcome; } this.pendingPostCompactRestore.set(input.sessionName, "compliance_prompt"); // OPR.0.4.3.14 — surface manual-trigger progress (no-op for auto seats). this.advanceManualStage(input.sessionName, "compact-sent", "restore-sent"); - return { triggered: true }; + return restore.outcome; } if (pendingStage === "compliance_prompt") { - const compliance = await this.sessionTransport.send( - input.sessionName, + const compliance = await sendAttempt( buildPostCompactCompliancePrompt(policy.postRestoreAuditInstruction), { waitForIdleMs: this.postCompactSendWaitMs }, ); if (!compliance.ok) { // Audit cannot overtake restore: only advances once the restore turn is // idle and this send delivers; a busy tick retries the SAME stage. - return { triggered: false, reason: "send_failed" }; + return compliance.outcome; } this.pendingPostCompactRestore.delete(input.sessionName); this.pendingStageGeneration.delete(input.sessionName); // GHOST-STAGE (b): stage completed → drop its gen @@ -448,7 +573,7 @@ export class ClaudeCompactionEnforcer { this.triggeredAboveThreshold.delete(input.sessionName); // OPR.0.4.3.14 — terminal manual-trigger stage (no-op for auto seats). this.advanceManualStage(input.sessionName, "restore-sent", "audit-sent"); - return { triggered: true }; + return compliance.outcome; } this.triggeredAboveThreshold.delete(input.sessionName); this.pendingPreCompactPrep.delete(input.sessionName); @@ -465,16 +590,19 @@ export class ClaudeCompactionEnforcer { // constant policy), while a manual trigger's restore/audit half can finish // via this single shared path even when auto-compaction is disabled. if (!policy.enabled) { - return { triggered: false, reason: "disabled" }; + if (!lift("disabled")) return { triggered: false, reason: "disabled" }; } const now = Date.now(); const postRestoreCooldownUntil = this.postCompactRestoreCooldownUntil.get(input.sessionName); if (postRestoreCooldownUntil !== undefined) { if (now < postRestoreCooldownUntil) { - return { triggered: false, reason: "post_restore_cooldown" }; + if (!lift("post_restore_cooldown")) { + return { triggered: false, reason: "post_restore_cooldown" }; + } + } else { + this.postCompactRestoreCooldownUntil.delete(input.sessionName); } - this.postCompactRestoreCooldownUntil.delete(input.sessionName); } const last = this.lastAutoCompactAt.get(input.sessionName); @@ -487,8 +615,7 @@ export class ClaudeCompactionEnforcer { const preCompactStage = this.pendingPreCompactPrep.get(input.sessionName); if (preCompactStage === undefined) { - const prep = await this.sessionTransport.send( - input.sessionName, + const prep = await sendAttempt( buildPreCompactPrepPrompt({ usedPercentage: input.usedPercentage, thresholdPercent: policy.thresholdPercent, @@ -496,18 +623,17 @@ export class ClaudeCompactionEnforcer { }), ); if (!prep.ok) { - return { triggered: false, reason: "send_failed" }; + return prep.outcome; } this.pendingPreCompactPrep.set(input.sessionName, "prep_prompt_sent"); - return { triggered: true }; + return prep.outcome; } - const result = await this.sessionTransport.send( - input.sessionName, + const attempt = await sendAttempt( buildCompactCommand(policy.compactInstruction), ); - if (!result.ok) { - return { triggered: false, reason: "send_failed" }; + if (!attempt.ok) { + return attempt.outcome; } this.lastAutoCompactAt.set(input.sessionName, now); this.triggeredAboveThreshold.add(input.sessionName); @@ -515,7 +641,7 @@ export class ClaudeCompactionEnforcer { this.pendingPostCompactRestore.set(input.sessionName, "turn_boundary"); // 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 { triggered: true }; + return attempt.outcome; } /** diff --git a/packages/daemon/src/domain/enforcer-decision-store.ts b/packages/daemon/src/domain/enforcer-decision-store.ts new file mode 100644 index 000000000..c65038616 --- /dev/null +++ b/packages/daemon/src/domain/enforcer-decision-store.ts @@ -0,0 +1,359 @@ +import type Database from "better-sqlite3"; +import { randomUUID } from "node:crypto"; + +export const CLAUDE_COMPACTION_ENFORCER_KIND = "claude_compaction" as const; +export const AUTHORIZABLE_COMPACTION_REASONS = [ + "disabled", + "post_restore_cooldown", + "stale_generation", +] as const; + +export type AuthorizableCompactionReason = typeof AUTHORIZABLE_COMPACTION_REASONS[number]; +export type EnforcerDecisionDirection = "hold" | "authorize"; + +export interface EnforcerDecision { + decisionId: string; + enforcerKind: string; + sessionName: string; + generationUuid: string; + direction: EnforcerDecisionDirection; + automaticReason: string | null; + reason: string; + actorSession: string; + identityProvenance: string; + createdAt: string; + expiresAt: string | null; + active: boolean; + releaseKind: string | null; + releasedAt: string | null; + releasedBySession: string | null; + releaseIdentityProvenance: string | null; + releaseReason: string | null; + consumedAt: string | null; + consumedByEnforcerKind: string | null; + liftedReason: string | null; + attemptOutcome: string | null; + attemptFailureReason: string | null; + lastObservedAt: string | null; + lastObservedOutcome: string | null; +} + +export interface CreateEnforcerDecisionInput { + enforcerKind: string; + sessionName: string; + generationUuid: string; + direction: EnforcerDecisionDirection; + automaticReason?: string | null; + reason: string; + actorSession: string; + identityProvenance: string; +} + +interface DecisionRow { + decision_id: string; + enforcer_kind: string; + session_name: string; + generation_uuid: string; + direction: EnforcerDecisionDirection; + automatic_reason: string | null; + reason: string; + actor_session: string; + identity_provenance: string; + created_at: string; + expires_at: string | null; + active: number; + release_kind: string | null; + released_at: string | null; + released_by_session: string | null; + release_identity_provenance: string | null; + release_reason: string | null; + consumed_at: string | null; + consumed_by_enforcer_kind: string | null; + lifted_reason: string | null; + attempt_outcome: string | null; + attempt_failure_reason: string | null; + last_observed_at: string | null; + last_observed_outcome: string | null; +} + +const AUTHORIZABLE = new Set(AUTHORIZABLE_COMPACTION_REASONS); + +function required(value: unknown, field: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${field} is required`); + } + return value.trim(); +} + +function mapDecision(row: DecisionRow): EnforcerDecision { + return { + decisionId: row.decision_id, + enforcerKind: row.enforcer_kind, + sessionName: row.session_name, + generationUuid: row.generation_uuid, + direction: row.direction, + automaticReason: row.automatic_reason, + reason: row.reason, + actorSession: row.actor_session, + identityProvenance: row.identity_provenance, + createdAt: row.created_at, + expiresAt: row.expires_at, + active: row.active === 1, + releaseKind: row.release_kind, + releasedAt: row.released_at, + releasedBySession: row.released_by_session, + releaseIdentityProvenance: row.release_identity_provenance, + releaseReason: row.release_reason, + consumedAt: row.consumed_at, + consumedByEnforcerKind: row.consumed_by_enforcer_kind, + liftedReason: row.lifted_reason, + attemptOutcome: row.attempt_outcome, + attemptFailureReason: row.attempt_failure_reason, + lastObservedAt: row.last_observed_at, + lastObservedOutcome: row.last_observed_outcome, + }; +} + +export class EnforcerDecisionStore { + constructor( + private readonly db: Database.Database, + private readonly opts: { now: () => Date; authorizeTtlMinutes: () => number }, + ) {} + + private nowIso(): string { + return this.opts.now().toISOString(); + } + + private expireAuthorizations(now = this.nowIso()): void { + this.db.prepare(` + UPDATE enforcer_decisions + SET active = 0, + release_kind = 'expired', + released_at = ? + WHERE active = 1 + AND direction = 'authorize' + AND expires_at IS NOT NULL + AND expires_at <= ? + `).run(now, now); + } + + private getById(decisionId: string): EnforcerDecision | null { + const row = this.db.prepare( + "SELECT * FROM enforcer_decisions WHERE decision_id = ?", + ).get(decisionId) as DecisionRow | undefined; + return row ? mapDecision(row) : null; + } + + create(input: CreateEnforcerDecisionInput): EnforcerDecision { + const enforcerKind = required(input.enforcerKind, "enforcer kind"); + const sessionName = required(input.sessionName, "session name"); + const generationUuid = required(input.generationUuid, "generation UUID"); + const reason = required(input.reason, "reason"); + const actorSession = required(input.actorSession, "actor session"); + const identityProvenance = required(input.identityProvenance, "identity provenance"); + if (input.direction !== "hold" && input.direction !== "authorize") { + throw new Error("direction must be hold or authorize"); + } + + let automaticReason: string | null = null; + if (input.direction === "authorize") { + automaticReason = required(input.automaticReason, "automatic reason"); + if (!AUTHORIZABLE.has(automaticReason)) { + throw new Error(`automatic reason '${automaticReason}' is not authorizable`); + } + } + + const now = this.nowIso(); + this.expireAuthorizations(now); + const decisionId = randomUUID(); + const ttlMinutes = this.opts.authorizeTtlMinutes(); + const expiresAt = input.direction === "authorize" + ? new Date(this.opts.now().getTime() + ttlMinutes * 60_000).toISOString() + : null; + + const transaction = this.db.transaction(() => { + const active = this.db.prepare(` + SELECT direction FROM enforcer_decisions + WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1 + `).get(enforcerKind, sessionName, generationUuid) as { direction: EnforcerDecisionDirection } | undefined; + + if (input.direction === "authorize" && active?.direction === "hold") { + throw new Error("cannot authorize while an active hold exists"); + } + if (active?.direction === input.direction) { + throw new Error(`an active ${input.direction} decision already exists`); + } + if (input.direction === "hold" && active?.direction === "authorize") { + this.db.prepare(` + UPDATE enforcer_decisions + SET active = 0, + release_kind = 'revoked_by_hold', + released_at = ?, + released_by_session = ?, + release_identity_provenance = ?, + release_reason = ? + WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? AND active = 1 + `).run( + now, + actorSession, + identityProvenance, + reason, + enforcerKind, + sessionName, + generationUuid, + ); + } + + this.db.prepare(` + INSERT INTO enforcer_decisions ( + decision_id, enforcer_kind, session_name, generation_uuid, direction, + automatic_reason, reason, actor_session, identity_provenance, + created_at, expires_at, active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + `).run( + decisionId, + enforcerKind, + sessionName, + generationUuid, + input.direction, + automaticReason, + reason, + actorSession, + identityProvenance, + now, + expiresAt, + ); + }); + transaction.immediate(); + return this.getById(decisionId)!; + } + + list(input: { sessionName?: string } = {}): EnforcerDecision[] { + this.expireAuthorizations(); + const rows = input.sessionName + ? this.db.prepare(` + SELECT * FROM enforcer_decisions WHERE session_name = ? + ORDER BY created_at DESC, rowid DESC + `).all(input.sessionName) as DecisionRow[] + : this.db.prepare(` + SELECT * FROM enforcer_decisions ORDER BY created_at DESC, rowid DESC + `).all() as DecisionRow[]; + return rows.map(mapDecision); + } + + clear(input: { + decisionId: string; + actorSession: string; + identityProvenance: string; + reason: string; + }): EnforcerDecision { + const decisionId = required(input.decisionId, "decision ID"); + const actorSession = required(input.actorSession, "actor session"); + const identityProvenance = required(input.identityProvenance, "identity provenance"); + const reason = required(input.reason, "reason"); + const result = this.db.prepare(` + UPDATE enforcer_decisions + SET active = 0, + release_kind = 'cleared', + released_at = ?, + released_by_session = ?, + release_identity_provenance = ?, + release_reason = ? + WHERE decision_id = ? AND active = 1 + `).run(this.nowIso(), actorSession, identityProvenance, reason, decisionId); + if (result.changes !== 1) throw new Error("active decision not found"); + return this.getById(decisionId)!; + } + + findActiveHold(input: { + enforcerKind: string; + sessionName: string; + liveGenerationUuid: string | null; + }): EnforcerDecision | null { + this.expireAuthorizations(); + const row = input.liveGenerationUuid === null + ? this.db.prepare(` + SELECT * FROM enforcer_decisions + WHERE enforcer_kind = ? AND session_name = ? AND direction = 'hold' AND active = 1 + ORDER BY created_at DESC, rowid DESC LIMIT 1 + `).get(input.enforcerKind, input.sessionName) as DecisionRow | undefined + : this.db.prepare(` + SELECT * FROM enforcer_decisions + WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? + AND direction = 'hold' AND active = 1 + LIMIT 1 + `).get(input.enforcerKind, input.sessionName, input.liveGenerationUuid) as DecisionRow | undefined; + return row ? mapDecision(row) : null; + } + + findMatchingAuthorization(input: { + enforcerKind: string; + sessionName: string; + liveGenerationUuid: string | null; + automaticReason: string; + }): EnforcerDecision | null { + this.expireAuthorizations(); + if (input.liveGenerationUuid === null) return null; + const row = this.db.prepare(` + SELECT * FROM enforcer_decisions + WHERE enforcer_kind = ? AND session_name = ? AND generation_uuid = ? + AND direction = 'authorize' AND automatic_reason = ? AND active = 1 + LIMIT 1 + `).get( + input.enforcerKind, + input.sessionName, + input.liveGenerationUuid, + input.automaticReason, + ) as DecisionRow | undefined; + return row ? mapDecision(row) : null; + } + + observeHold(input: { decisionId: string; outcome: "human_hold" }): void { + this.db.prepare(` + UPDATE enforcer_decisions + SET last_observed_at = ?, last_observed_outcome = ? + WHERE decision_id = ? AND direction = 'hold' AND active = 1 + `).run(this.nowIso(), input.outcome, input.decisionId); + } + + consumeAuthorizationForAttempt(input: { + decisionId: string; + enforcerKind: string; + liftedReason: string; + }): boolean { + const now = this.nowIso(); + this.expireAuthorizations(now); + const result = this.db.prepare(` + UPDATE enforcer_decisions + SET active = 0, + consumed_at = ?, + consumed_by_enforcer_kind = ?, + lifted_reason = ? + WHERE decision_id = ? + AND direction = 'authorize' + AND automatic_reason = ? + AND active = 1 + AND expires_at > ? + `).run( + now, + input.enforcerKind, + input.liftedReason, + input.decisionId, + input.liftedReason, + now, + ); + return result.changes === 1; + } + + recordAuthorizationAttempt(input: { + decisionId: string; + outcome: "succeeded" | "failed"; + failureReason?: string; + }): void { + this.db.prepare(` + UPDATE enforcer_decisions + SET attempt_outcome = ?, attempt_failure_reason = ? + WHERE decision_id = ? AND consumed_at IS NOT NULL + `).run(input.outcome, input.failureReason ?? null, input.decisionId); + } +} diff --git a/packages/daemon/src/domain/user-settings/settings-store.ts b/packages/daemon/src/domain/user-settings/settings-store.ts index 88de40427..51ee5a0bb 100644 --- a/packages/daemon/src/domain/user-settings/settings-store.ts +++ b/packages/daemon/src/domain/user-settings/settings-store.ts @@ -116,6 +116,7 @@ export const SETTINGS_VALID_KEYS = [ "policies.claude_compaction.message_inline", "policies.claude_compaction.message_file_path", "policies.claude_compaction.post_restore_audit_instruction", + "policies.claude_compaction.authorize_ttl_minutes", "policies.idle_gate_qitem.scan_interval_seconds", "policies.idle_gate_qitem.active_wake_interval_seconds", "snapshots.periodic.enabled", @@ -192,6 +193,7 @@ const ENV_MAP: Record = "policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" }, "policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" }, "policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" }, + "policies.claude_compaction.authorize_ttl_minutes": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES" }, "policies.idle_gate_qitem.scan_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS" }, "policies.idle_gate_qitem.active_wake_interval_seconds": { primary: "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS" }, "snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" }, @@ -249,6 +251,7 @@ const KEY_TO_PATH: Record = { "policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"], "policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"], "policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"], + "policies.claude_compaction.authorize_ttl_minutes": ["policies", "claudeCompaction", "authorizeTtlMinutes"], "policies.idle_gate_qitem.scan_interval_seconds": ["policies", "idleGateQitem", "scanIntervalSeconds"], "policies.idle_gate_qitem.active_wake_interval_seconds": ["policies", "idleGateQitem", "activeWakeIntervalSeconds"], "snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"], @@ -477,6 +480,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string | case "policies.claude_compaction.message_inline": return DEFAULT_CLAUDE_COMPACTION_RESTORE_INSTRUCTION; case "policies.claude_compaction.message_file_path": return defaultClaudeCompactionExtraInstructionFilePath(); case "policies.claude_compaction.post_restore_audit_instruction": return DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION; + case "policies.claude_compaction.authorize_ttl_minutes": return 15; case "policies.idle_gate_qitem.scan_interval_seconds": return 60; case "policies.idle_gate_qitem.active_wake_interval_seconds": return 900; case "snapshots.periodic.enabled": return true; @@ -552,6 +556,19 @@ const KEY_CONSTRAINTS: Partial { + const trimmed = (raw ?? "").trim(); + if (!/^-?\d+$/.test(trimmed) || typeof coerced !== "number" || !Number.isInteger(coerced)) { + throw new Error( + `Invalid value for policies.claude_compaction.authorize_ttl_minutes: expected an integer in [1, 60], got "${raw}"`, + ); + } + if (coerced < 1 || coerced > 60) { + throw new Error( + `Invalid value for policies.claude_compaction.authorize_ttl_minutes: must be in [1, 60], got ${coerced}`, + ); + } + }, "snapshots.periodic.interval_seconds": (raw, coerced) => { if (!/^-?\d+$/.test((raw ?? "").trim())) { throw new Error( @@ -643,6 +660,7 @@ export interface ClaudeCompactionPolicy { messageInline: string; messageFilePath: string; postRestoreAuditInstruction: string; + authorizeTtlMinutes: number; } export interface ResolvedConfig { @@ -763,6 +781,7 @@ export class SettingsStore { messageInline: this.resolveOne("policies.claude_compaction.message_inline", fc, wr).value as string, messageFilePath: this.resolveOne("policies.claude_compaction.message_file_path", fc, wr).value as string, postRestoreAuditInstruction: this.resolveOne("policies.claude_compaction.post_restore_audit_instruction", fc, wr).value as string, + authorizeTtlMinutes: this.resolveOne("policies.claude_compaction.authorize_ttl_minutes", fc, wr).value as number, }; } diff --git a/packages/daemon/src/routes/compaction.ts b/packages/daemon/src/routes/compaction.ts index af62f2d0f..3c278a8b4 100644 --- a/packages/daemon/src/routes/compaction.ts +++ b/packages/daemon/src/routes/compaction.ts @@ -3,7 +3,14 @@ import type Database from "better-sqlite3"; import type { ClaudeCompactionEnforcer } from "../domain/claude-compaction-enforcer.js"; import type { ContextUsageStore } from "../domain/context-usage-store.js"; import type { SessionTransport } from "../domain/session-transport.js"; +import type { SessionRegistry } from "../domain/session-registry.js"; +import { + AUTHORIZABLE_COMPACTION_REASONS, + CLAUDE_COMPACTION_ENFORCER_KIND, + type EnforcerDecisionStore, +} from "../domain/enforcer-decision-store.js"; import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js"; +import { requireSenderIdentity } from "./require-sender-identity.js"; /** * OPR.0.4.3.14 — manual configurable compaction trigger route. @@ -21,6 +28,98 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono { const router = new Hono(); router.use("*", authBearerTokenMiddleware({ expectedToken: opts?.bearerToken ?? null })); + router.post("/control", async (c) => { + const identity = requireSenderIdentity(c, { verb: "compaction control create" }); + if (!identity.ok) return identity.response; + const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined; + const sessionRegistry = c.get("sessionRegistry" as never) as SessionRegistry | undefined; + if (!store || !sessionRegistry) { + return c.json({ ok: false, error: "compaction_control_unavailable" }, 503); + } + + type ControlBody = { + session?: string; + direction?: string; + automaticReason?: string; + reason?: string; + }; + const body = await c.req.json().catch((): ControlBody => ({})); + const sessionName = body.session?.trim(); + if (!sessionName) return c.json({ ok: false, error: "session_required" }, 400); + const reason = body.reason?.trim(); + if (!reason) return c.json({ ok: false, error: "reason_required" }, 400); + if (body.direction !== "hold" && body.direction !== "authorize") { + return c.json({ ok: false, error: "direction_invalid" }, 400); + } + + let automaticReason: string | null = null; + if (body.direction === "authorize") { + automaticReason = body.automaticReason?.trim() ?? ""; + if (!automaticReason) { + return c.json({ ok: false, error: "automatic_reason_required" }, 400); + } + if (!(AUTHORIZABLE_COMPACTION_REASONS as readonly string[]).includes(automaticReason)) { + return c.json({ ok: false, error: "automatic_reason_not_authorizable" }, 400); + } + } + + const generationUuid = sessionRegistry.currentOccupantGenerationForSession(sessionName); + if (!generationUuid) { + return c.json({ ok: false, error: "generation_unavailable" }, 409); + } + + try { + const decision = store.create({ + enforcerKind: CLAUDE_COMPACTION_ENFORCER_KIND, + sessionName, + generationUuid, + direction: body.direction, + automaticReason, + reason, + actorSession: identity.session, + identityProvenance: "transport:v1", + }); + return c.json({ ok: true, decision }, 201); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const status = /active hold|already exists/i.test(message) ? 409 : 400; + return c.json({ ok: false, error: "decision_rejected", message }, status as 400 | 409); + } + }); + + router.get("/control", (c) => { + const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined; + if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503); + const sessionName = c.req.query("session")?.trim(); + return c.json({ + ok: true, + decisions: store.list(sessionName ? { sessionName } : {}), + }); + }); + + router.post("/control/:decisionId/clear", async (c) => { + const identity = requireSenderIdentity(c, { verb: "compaction control clear" }); + if (!identity.ok) return identity.response; + const store = c.get("enforcerDecisionStore" as never) as EnforcerDecisionStore | undefined; + if (!store) return c.json({ ok: false, error: "compaction_control_unavailable" }, 503); + type ClearBody = { reason?: string }; + const body = await c.req.json().catch((): ClearBody => ({})); + const reason = body.reason?.trim(); + if (!reason) return c.json({ ok: false, error: "reason_required" }, 400); + try { + const decision = store.clear({ + decisionId: c.req.param("decisionId"), + actorSession: identity.session, + identityProvenance: "transport:v1", + reason, + }); + return c.json({ ok: true, decision }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return c.json({ ok: false, error: "decision_not_active", message }, 404); + } + }); + router.post("/trigger", async (c) => { const enforcer = c.get("compactionEnforcer" as never) as ClaudeCompactionEnforcer | undefined; const transport = c.get("sessionTransport" as never) as SessionTransport | undefined; diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index 3af96b704..3dfbb5f30 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -292,6 +292,7 @@ export interface AppDeps { * auto share one back-half state machine — no second restore path). */ compactionEnforcer?: ClaudeCompactionEnforcer; + enforcerDecisionStore?: import("./domain/enforcer-decision-store.js").EnforcerDecisionStore; /** * GHOST-STAGE (e/Class-B) — the canonical OccupantInvalidator, constructed once in startup and * injected so SeatHandoverService.commit()'s re-key call actually FIRES (dev-driver's fold added @@ -530,6 +531,7 @@ export function createApp(deps: AppDeps): Hono { c.set("contextUsageStore" as never, deps.contextUsageStore); c.set("contextMonitor" as never, deps.contextMonitor); c.set("compactionEnforcer" as never, deps.compactionEnforcer); + c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore); c.set("occupantInvalidator" as never, deps.occupantInvalidator); c.set("nodeCmuxService" as never, deps.nodeCmuxService); c.set("agentActivityStore" as never, deps.agentActivityStore); diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 74c4ec8b4..3bd2864e9 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -1757,14 +1757,24 @@ export async function createDaemon(opts?: DaemonOptions): Promise // the type narrow for downstream consumers). const { ContextMonitor } = await import("./domain/context-monitor.js"); const { ClaudeCompactionEnforcer } = await import("./domain/claude-compaction-enforcer.js"); + const { EnforcerDecisionStore } = await import("./domain/enforcer-decision-store.js"); + const compactionSettingsStore = new ContextPackSettingsStore(); + const enforcerDecisionStore = new EnforcerDecisionStore(db, { + now: () => new Date(), + authorizeTtlMinutes: () => compactionSettingsStore.resolveClaudeCompactionPolicy().authorizeTtlMinutes, + }); + deps.enforcerDecisionStore = enforcerDecisionStore; const compactionEnforcer = deps.sessionTransport ? new ClaudeCompactionEnforcer( - new ContextPackSettingsStore(), + compactionSettingsStore, deps.sessionTransport, // GHOST-STAGE (b): resolve the LIVE occupant generation (atom-B tenure ledger) for a session // so a stage minted by a retired generation is refused for the successor. null = UNKNOWN → the // gate is inert (never compares a stale generation as if live). - { resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName) }, + { + resolveOccupantGeneration: (sessionName) => sessionRegistry.currentOccupantGenerationForSession(sessionName), + decisionStore: enforcerDecisionStore, + }, ) : undefined; // 51-08 A1 — the over-time series rides the SAME 30s tick (PM decision 1): diff --git a/packages/daemon/test/user-settings-store.test.ts b/packages/daemon/test/user-settings-store.test.ts index ba767396f..e1c4b4016 100644 --- a/packages/daemon/test/user-settings-store.test.ts +++ b/packages/daemon/test/user-settings-store.test.ts @@ -49,6 +49,7 @@ function clearEnv(): () => void { "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE", "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH", "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION", + "OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES", "OPENRIG_POLICIES_IDLE_GATE_QITEM_SCAN_INTERVAL_SECONDS", "OPENRIG_POLICIES_IDLE_GATE_QITEM_ACTIVE_WAKE_INTERVAL_SECONDS", "RIGGED_PORT", "RIGGED_HOST", "RIGGED_DB", @@ -125,6 +126,7 @@ describe("SettingsStore (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 daemon auto-registration cadence. "policies.idle_gate_qitem.scan_interval_seconds", "policies.idle_gate_qitem.active_wake_interval_seconds", @@ -339,6 +341,7 @@ describe("SettingsStore (User Settings v0)", () => { expect(policy.messageFilePath).toMatch(/^\//); expect(policy.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true); expect(policy.postRestoreAuditInstruction).toContain(DEFAULT_AUDIT_INSTRUCTION_FRAGMENT); + expect(policy.authorizeTtlMinutes).toBe(15); }); it("HG-10: resolveClaudeCompactionPolicy picks up direct config.json edits without daemon restart (single resolve call rereads file)", () => { @@ -357,6 +360,7 @@ describe("SettingsStore (User Settings v0)", () => { messageInline: "carry-forward note", messageFilePath: "", postRestoreAuditInstruction: "verify the reads", + authorizeTtlMinutes: 30, }, }, }), @@ -370,6 +374,7 @@ describe("SettingsStore (User Settings v0)", () => { expect(updated.messageInline).toBe("carry-forward note"); expect(updated.messageFilePath.endsWith(DEFAULT_EXTRA_INSTRUCTION_FILE_SUFFIX)).toBe(true); expect(updated.postRestoreAuditInstruction).toBe("verify the reads"); + expect(updated.authorizeTtlMinutes).toBe(30); }); it("Slice 27: ensureDefaultClaudeCompactionFiles creates the user-owned extra instruction placeholder without overwriting edits", () => { @@ -396,6 +401,7 @@ describe("SettingsStore (User Settings v0)", () => { store.set("policies.claude_compaction.message_inline", "rehydrate the agent"); store.set("policies.claude_compaction.message_file_path", "/tmp/msg.txt"); store.set("policies.claude_compaction.post_restore_audit_instruction", "audit reads"); + store.set("policies.claude_compaction.authorize_ttl_minutes", "30"); const raw = JSON.parse(require("node:fs").readFileSync(configPath, "utf-8")); expect(raw.policies.claudeCompaction.enabled).toBe(true); @@ -405,6 +411,7 @@ describe("SettingsStore (User Settings v0)", () => { expect(raw.policies.claudeCompaction.messageInline).toBe("rehydrate the agent"); expect(raw.policies.claudeCompaction.messageFilePath).toBe("/tmp/msg.txt"); expect(raw.policies.claudeCompaction.postRestoreAuditInstruction).toBe("audit reads"); + expect(raw.policies.claudeCompaction.authorizeTtlMinutes).toBe(30); expect(store.resolveOne("policies.claude_compaction.enabled").value).toBe(true); expect(store.resolveOne("policies.claude_compaction.threshold_percent").value).toBe(60); @@ -413,6 +420,7 @@ describe("SettingsStore (User Settings v0)", () => { expect(store.resolveOne("policies.claude_compaction.message_inline").value).toBe("rehydrate the agent"); expect(store.resolveOne("policies.claude_compaction.message_file_path").value).toBe("/tmp/msg.txt"); expect(store.resolveOne("policies.claude_compaction.post_restore_audit_instruction").value).toBe("audit reads"); + expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes").value).toBe(30); }); it("HG-1: invalid threshold rejected by coerceValue (non-numeric raises)", () => { @@ -420,6 +428,70 @@ describe("SettingsStore (User Settings v0)", () => { expect(() => store.set("policies.claude_compaction.threshold_percent", "not-a-number")).toThrow(/expected a number/); }); + describe("W4 authorize TTL config contract", () => { + it("accepts the exact inclusive 1..60 range through the write path", () => { + const store = new SettingsStore(configPath); + for (const value of ["1", "15", "60"]) { + store.set("policies.claude_compaction.authorize_ttl_minutes", value); + expect(store.resolveOne("policies.claude_compaction.authorize_ttl_minutes")).toMatchObject({ + value: Number(value), + source: "file", + defaultValue: 15, + }); + } + for (const value of ["0", "61", "15.5", "bad"]) { + expect(() => store.set("policies.claude_compaction.authorize_ttl_minutes", value)).toThrow(/integer|\[1, 60\]|number/i); + } + }); + + it("uses a valid env override", () => { + process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = "30"; + expect(new SettingsStore(configPath).resolveOne( + "policies.claude_compaction.authorize_ttl_minutes", + )).toMatchObject({ value: 30, source: "env", defaultValue: 15 }); + }); + + it.each(["0", "61", "15.5", "bad"])( + "invalid env %s falls back to 15 and reports loudly", + (raw) => { + process.env["OPENRIG_POLICIES_CLAUDE_COMPACTION_AUTHORIZE_TTL_MINUTES"] = raw; + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const resolved = new SettingsStore(configPath).resolveOne( + "policies.claude_compaction.authorize_ttl_minutes", + ); + expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 }); + expect(stderr.mock.calls.some((call) => String(call[0]).includes( + "env override for policies.claude_compaction.authorize_ttl_minutes rejected", + ))).toBe(true); + } finally { + stderr.mockRestore(); + } + }, + ); + + it.each([0, 61, 15.5, "bad"])( + "invalid file value %s falls back to 15 and reports loudly", + (raw) => { + writeFileSync(configPath, JSON.stringify({ + policies: { claudeCompaction: { authorizeTtlMinutes: raw } }, + })); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + const resolved = new SettingsStore(configPath).resolveOne( + "policies.claude_compaction.authorize_ttl_minutes", + ); + expect(resolved).toMatchObject({ value: 15, source: "default", defaultValue: 15 }); + expect(stderr.mock.calls.some((call) => String(call[0]).includes( + "file value for policies.claude_compaction.authorize_ttl_minutes rejected", + ))).toBe(true); + } finally { + stderr.mockRestore(); + } + }, + ); + }); + // Slice 27 BLOCKING-FIX-2 — env + file source resolution must also // reject invalid threshold values. Without this, an env override // (OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT=80abc) or a diff --git a/packages/daemon/test/w4-enforcement-context-monitor.test.ts b/packages/daemon/test/w4-enforcement-context-monitor.test.ts new file mode 100644 index 000000000..7e26daef5 --- /dev/null +++ b/packages/daemon/test/w4-enforcement-context-monitor.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type Database from "better-sqlite3"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createDb } from "../src/db/connection.js"; +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 { ContextUsageStore } from "../src/domain/context-usage-store.js"; +import { ContextMonitor } from "../src/domain/context-monitor.js"; +import { ClaudeCompactionEnforcer } from "../src/domain/claude-compaction-enforcer.js"; +import type { SessionTransport } from "../src/domain/session-transport.js"; + +const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/"); +const SESSION = "claude-seat@rig"; + +type DecisionStore = { + create(input: Record): Record; + list(input?: Record): Array>; +}; + +type DecisionStoreConstructor = new ( + db: Database.Database, + opts: { now: () => Date; authorizeTtlMinutes: () => number }, +) => DecisionStore; + +async function loadDecisionStore(): Promise { + let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null; + try { + mod = await import(/* @vite-ignore */ STORE_MODULE); + } catch { + // Assertion below keeps the RED feature-shaped instead of surfacing a loader error. + } + expect(mod, "durable enforcer decision store module must exist").not.toBeNull(); + expect(mod?.EnforcerDecisionStore).toBeTypeOf("function"); + return mod!.EnforcerDecisionStore!; +} + +describe("W4 ContextMonitor enforcement observation", () => { + let db: Database.Database; + let stateDir: string; + let contextStore: ContextUsageStore; + let monitor: ContextMonitor | undefined; + let generationUuid: string; + + beforeEach(() => { + db = createDb(); + migrate(db, ALL_MIGRATIONS); + stateDir = join(tmpdir(), `w4-context-monitor-${process.pid}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(stateDir, "context"), { recursive: true }); + contextStore = new ContextUsageStore(db, { stateDir, codexHomeDir: stateDir }); + + const rigs = new RigRepository(db); + const sessions = new SessionRegistry(db); + const rig = rigs.createRig("rig"); + const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" }); + const session = sessions.registerSession(node.id, SESSION); + generationUuid = sessions.currentOccupantGenerationForSession(SESSION)!; + db.prepare("UPDATE sessions SET status = 'running' WHERE id = ?").run(session.id); + + writeFileSync( + join(stateDir, "context", `${SESSION}.json`), + JSON.stringify({ + session_name: SESSION, + sampled_at: "2026-08-08T18:00:30.000Z", + context_window: { + context_window_size: 200_000, + used_percentage: 90, + remaining_percentage: 10, + total_input_tokens: 170_000, + total_output_tokens: 10_000, + }, + }), + ); + }); + + afterEach(() => { + monitor?.stop(); + db.close(); + rmSync(stateDir, { recursive: true, force: true }); + }); + + it("a real poll honors a hold with unknown live generation and exposes its observation", async () => { + const EnforcerDecisionStore = await loadDecisionStore(); + const decisions = new EnforcerDecisionStore(db, { + now: () => new Date("2026-08-08T18:00:30.000Z"), + authorizeTtlMinutes: () => 15, + }); + const hold = decisions.create({ + enforcerKind: "claude_compaction", + sessionName: SESSION, + generationUuid, + direction: "hold", + reason: "finish the current atomic action", + actorSession: "orch-lead@rig", + identityProvenance: "transport:v1", + }); + const send = vi.fn(async () => ({ ok: true as const })); + const enforcer = new ClaudeCompactionEnforcer( + { + resolveClaudeCompactionPolicy: () => ({ + enabled: true, + thresholdPercent: 80, + preCompactInstruction: "prepare", + compactInstruction: "", + messageInline: "", + messageFilePath: "", + postRestoreAuditInstruction: "audit", + authorizeTtlMinutes: 15, + }), + } as never, + { send } as unknown as SessionTransport, + { + resolveOccupantGeneration: () => null, + decisionStore: decisions, + } as never, + ); + monitor = new ContextMonitor( + db, + contextStore, + { ensureContextCollector: vi.fn() }, + enforcer, + ); + + await monitor.pollOnce(); + + expect(send).not.toHaveBeenCalled(); + expect(decisions.list({ sessionName: SESSION })).toContainEqual(expect.objectContaining({ + decisionId: hold["decisionId"], + lastObservedAt: "2026-08-08T18:00:30.000Z", + lastObservedOutcome: "human_hold", + })); + }); +}); diff --git a/packages/daemon/test/w4-enforcement-routes.test.ts b/packages/daemon/test/w4-enforcement-routes.test.ts new file mode 100644 index 000000000..af143ee79 --- /dev/null +++ b/packages/daemon/test/w4-enforcement-routes.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Hono } from "hono"; +import type Database from "better-sqlite3"; +import { createDb } from "../src/db/connection.js"; +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 { compactionRoutes } from "../src/routes/compaction.js"; + +const IDENTITY = "orch-lead@rig"; +const SEAT = "claude-seat@rig"; + +describe("W4 compaction-control routes", () => { + let db: Database.Database; + let sessionRegistry: SessionRegistry; + let generationUuid: string; + let decisions: { + create: ReturnType; + list: ReturnType; + clear: ReturnType; + }; + let app: Hono; + + beforeEach(() => { + db = createDb(); + migrate(db, ALL_MIGRATIONS); + const rigs = new RigRepository(db); + sessionRegistry = new SessionRegistry(db); + const rig = rigs.createRig("rig"); + const node = rigs.addNode(rig.id, "claude.seat", { runtime: "claude-code" }); + sessionRegistry.registerSession(node.id, SEAT); + generationUuid = sessionRegistry.currentOccupantGenerationForSession(SEAT)!; + decisions = { + create: vi.fn((input) => ({ decisionId: "decision-1", active: true, ...input })), + list: vi.fn(() => [{ + decisionId: "hold-1", + sessionName: SEAT, + direction: "hold", + active: true, + lastObservedAt: "2026-08-08T18:00:30.000Z", + lastObservedOutcome: "human_hold", + }]), + clear: vi.fn((input) => ({ decisionId: input.decisionId, active: false, releaseKind: "cleared" })), + }; + 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); + await next(); + }); + app.route("/api/compaction", compactionRoutes()); + }); + + afterEach(() => db.close()); + + const request = (path: string, body?: Record, identity = IDENTITY) => + app.request(path, { + method: body === undefined ? "GET" : "POST", + headers: { + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + ...(identity ? { "X-OpenRig-Session": identity } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + it("refuses an unattributable hold before writing any record", async () => { + const response = await request("/api/compaction/control", { + session: SEAT, + direction: "hold", + reason: "mid-fold", + }, ""); + expect(response.status).toBe(401); + expect(await response.json()).toMatchObject({ error: "unattributable_sender" }); + expect(decisions.create).not.toHaveBeenCalled(); + }); + + it("refuses blank reasons and blanket authorizations loudly", async () => { + const blank = await request("/api/compaction/control", { + session: SEAT, + direction: "hold", + reason: " ", + }); + expect(blank.status).toBe(400); + expect(await blank.json()).toMatchObject({ error: "reason_required" }); + + const blanket = await request("/api/compaction/control", { + session: SEAT, + direction: "authorize", + reason: "ignore refusals", + }); + expect(blanket.status).toBe(400); + expect(await blanket.json()).toMatchObject({ error: "automatic_reason_required" }); + expect(decisions.create).not.toHaveBeenCalled(); + }); + + it.each([ + "runtime_filter", + "no_usage_data", + "invalid_policy", + "below_threshold", + "already_triggered_above_threshold", + "dedup_window", + "send_failed", + ])("refuses excluded authorization reason %s", async (automaticReason) => { + const response = await request("/api/compaction/control", { + session: SEAT, + direction: "authorize", + automaticReason, + reason: "not in locked set", + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: "automatic_reason_not_authorizable" }); + expect(decisions.create).not.toHaveBeenCalled(); + }); + + it("derives actor/provenance and generation when creating a hold", async () => { + const response = await request("/api/compaction/control", { + session: SEAT, + direction: "hold", + reason: "finish current atomic action", + }); + expect(response.status).toBe(201); + expect(decisions.create).toHaveBeenCalledWith({ + enforcerKind: "claude_compaction", + sessionName: SEAT, + generationUuid, + direction: "hold", + automaticReason: null, + reason: "finish current atomic action", + actorSession: IDENTITY, + identityProvenance: "transport:v1", + }); + }); + + it.each(["disabled", "post_restore_cooldown", "stale_generation"])( + "creates one reason-matched authorization for %s without accepting caller expiry", + async (automaticReason) => { + const response = await request("/api/compaction/control", { + session: SEAT, + direction: "authorize", + automaticReason, + reason: `allow ${automaticReason} once`, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + expect(response.status).toBe(201); + expect(decisions.create).toHaveBeenCalledWith({ + enforcerKind: "claude_compaction", + sessionName: SEAT, + generationUuid, + direction: "authorize", + automaticReason, + reason: `allow ${automaticReason} once`, + actorSession: IDENTITY, + identityProvenance: "transport:v1", + }); + expect(decisions.create.mock.calls[0]?.[0]).not.toHaveProperty("expiresAt"); + }, + ); + + it("refuses decision creation when live generation cannot be resolved", async () => { + const response = await request("/api/compaction/control", { + session: "missing-seat@rig", + direction: "hold", + reason: "cannot scope without a generation", + }); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ error: "generation_unavailable" }); + expect(decisions.create).not.toHaveBeenCalled(); + }); + + 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); + expect(await response.json()).toEqual({ + ok: true, + decisions: [{ + decisionId: "hold-1", + sessionName: SEAT, + direction: "hold", + active: true, + lastObservedAt: "2026-08-08T18:00:30.000Z", + lastObservedOutcome: "human_hold", + }], + }); + expect(decisions.list).toHaveBeenCalledWith({ sessionName: SEAT }); + }); + + it("clear derives its human actor and records a required release reason", async () => { + const response = await request("/api/compaction/control/hold-1/clear", { + reason: "atomic action completed", + }); + expect(response.status).toBe(200); + expect(decisions.clear).toHaveBeenCalledWith({ + decisionId: "hold-1", + actorSession: IDENTITY, + identityProvenance: "transport:v1", + reason: "atomic action completed", + }); + }); +}); diff --git a/packages/daemon/test/w4-enforcement-startup-wiring.test.ts b/packages/daemon/test/w4-enforcement-startup-wiring.test.ts new file mode 100644 index 000000000..5dd56eb43 --- /dev/null +++ b/packages/daemon/test/w4-enforcement-startup-wiring.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +describe("W4 production wiring", () => { + it("constructs one decision store and shares it with the enforcer and routes", () => { + const startup = readFileSync(join(here, "../src/startup.ts"), "utf8"); + const server = readFileSync(join(here, "../src/server.ts"), "utf8"); + + expect(startup).toContain('import("./domain/enforcer-decision-store.js")'); + expect(startup.match(/new EnforcerDecisionStore\(/g)).toHaveLength(1); + expect(startup).toMatch(/decisionStore:\s*enforcerDecisionStore/); + expect(startup).toMatch(/deps\.enforcerDecisionStore\s*=\s*enforcerDecisionStore/); + + expect(server).toMatch(/enforcerDecisionStore\?:\s*import\("\.\/domain\/enforcer-decision-store\.js"\)\.EnforcerDecisionStore/); + expect(server).toContain('c.set("enforcerDecisionStore" as never, deps.enforcerDecisionStore)'); + expect(server).toContain("compactionRoutes"); + }); +}); diff --git a/packages/daemon/test/w4-enforcement-surface.test.ts b/packages/daemon/test/w4-enforcement-surface.test.ts new file mode 100644 index 000000000..97657ab70 --- /dev/null +++ b/packages/daemon/test/w4-enforcement-surface.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, it, vi } from "vitest"; +import type Database from "better-sqlite3"; +import { createDb } from "../src/db/connection.js"; +import { migrate } from "../src/db/migrate.js"; +import { ALL_MIGRATIONS } from "../src/db/all-migrations.js"; +import { + ClaudeCompactionEnforcer, + type EnforcerInput, +} from "../src/domain/claude-compaction-enforcer.js"; +import type { SessionTransport } from "../src/domain/session-transport.js"; + +const STORE_MODULE = ["../src/domain", "enforcer-decision-store.js"].join("/"); +const KIND = "claude_compaction"; +const SEAT = "claude-seat@rig"; + +type DecisionStoreConstructor = new ( + db: Database.Database, + opts: { now: () => Date; authorizeTtlMinutes: () => number }, +) => { + create(input: Record): Record; + list(input?: Record): Array>; + clear(input: Record): Record; + findActiveHold(input: Record): Record | null; + consumeAuthorizationForAttempt(input: Record): boolean; + recordAuthorizationAttempt(input: Record): void; +}; + +async function loadDecisionStore(): Promise { + let mod: { EnforcerDecisionStore?: DecisionStoreConstructor } | null = null; + try { + mod = await import(/* @vite-ignore */ STORE_MODULE); + } catch { + // Assertion below keeps the RED feature-shaped instead of surfacing a loader error. + } + expect(mod, "durable enforcer decision store module must exist").not.toBeNull(); + expect(mod?.EnforcerDecisionStore).toBeTypeOf("function"); + return mod!.EnforcerDecisionStore!; +} + +function makeDb(): Database.Database { + const db = createDb(); + migrate(db, ALL_MIGRATIONS); + return db; +} + +function humanInput(overrides: Record = {}): Record { + return { + enforcerKind: KIND, + sessionName: SEAT, + generationUuid: "gen-1", + direction: "hold", + reason: "seat is inside a load-bearing atomic step", + actorSession: "orch-lead@rig", + identityProvenance: "transport:v1", + ...overrides, + }; +} + +describe("W4 durable enforcer decision store", () => { + it("registers migration 068 and creates the durable decision table", () => { + expect(ALL_MIGRATIONS.map((migration) => migration.name)).toContain( + "068_enforcer_decisions.sql", + ); + const db = makeDb(); + try { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'enforcer_decisions'", + ).get() as { name: string } | undefined; + expect(row?.name).toBe("enforcer_decisions"); + } finally { + db.close(); + } + }); + + it("persists an attributable generation-scoped hold with enforcer kind", 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 created = store.create(humanInput()); + expect(created).toMatchObject({ + enforcerKind: KIND, + sessionName: SEAT, + generationUuid: "gen-1", + direction: "hold", + actorSession: "orch-lead@rig", + identityProvenance: "transport:v1", + reason: "seat is inside a load-bearing atomic step", + active: true, + }); + } finally { + db.close(); + } + }); + + it("makes authorize expiring, reason-matched, and limited to the locked lift set", 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, + }); + for (const [index, automaticReason] of [ + "disabled", + "post_restore_cooldown", + "stale_generation", + ].entries()) { + const created = store.create(humanInput({ + generationUuid: `gen-${index + 1}`, + direction: "authorize", + automaticReason, + reason: `lift ${automaticReason} once`, + })); + expect(created).toMatchObject({ + direction: "authorize", + automaticReason, + expiresAt: "2026-08-08T18:15:00.000Z", + active: true, + }); + } + + for (const automaticReason of [ + "runtime_filter", + "no_usage_data", + "invalid_policy", + "below_threshold", + "already_triggered_above_threshold", + "dedup_window", + "send_failed", + "human_hold", + ]) { + expect(() => store.create(humanInput({ + generationUuid: `excluded-${automaticReason}`, + direction: "authorize", + automaticReason, + reason: "must be refused", + }))).toThrow(/automatic reason|not authorizable|liftable/i); + } + } finally { + db.close(); + } + }); + + it("enforces one active decision and resolves conflicts toward hold", 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 authorization = store.create(humanInput({ + direction: "authorize", + automaticReason: "disabled", + reason: "one disabled-policy attempt", + })); + const hold = store.create(humanInput({ reason: "new work began; stop before acting" })); + expect(hold).toMatchObject({ direction: "hold", active: true }); + const rows = store.list({ sessionName: SEAT }); + expect(rows.find((row) => row["decisionId"] === authorization["decisionId"])).toMatchObject({ + active: false, + releaseKind: "revoked_by_hold", + releasedBySession: "orch-lead@rig", + releaseIdentityProvenance: "transport:v1", + }); + expect(() => store.create(humanInput({ + direction: "authorize", + automaticReason: "disabled", + reason: "must not outrank hold", + }))).toThrow(/active hold/i); + } finally { + db.close(); + } + }); + + it("consumes one authorization atomically for one machine send attempt", 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 created = store.create(humanInput({ + direction: "authorize", + automaticReason: "disabled", + reason: "allow one disabled-policy attempt", + })); + const consumeInput = { + decisionId: created["decisionId"], + enforcerKind: KIND, + liftedReason: "disabled", + }; + expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(true); + expect(store.consumeAuthorizationForAttempt(consumeInput)).toBe(false); + store.recordAuthorizationAttempt({ + decisionId: created["decisionId"], + outcome: "failed", + failureReason: "send_failed", + }); + expect(store.list({ sessionName: SEAT })[0]).toMatchObject({ + consumedByEnforcerKind: KIND, + liftedReason: "disabled", + attemptOutcome: "failed", + attemptFailureReason: "send_failed", + }); + expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumedByActorSession"); + expect(store.list({ sessionName: SEAT })[0]).not.toHaveProperty("consumeIdentityProvenance"); + } finally { + db.close(); + } + }); + + it("records explicit human clear and lazy expiry without erasing history", async () => { + const EnforcerDecisionStore = await loadDecisionStore(); + const db = makeDb(); + let now = new Date("2026-08-08T18:00:00.000Z"); + try { + const store = new EnforcerDecisionStore(db, { + now: () => now, + authorizeTtlMinutes: () => 15, + }); + const hold = store.create(humanInput()); + store.clear({ + decisionId: hold["decisionId"], + actorSession: "orch-lead@rig", + identityProvenance: "transport:v1", + reason: "atomic step completed", + }); + expect(store.list({ sessionName: SEAT })[0]).toMatchObject({ + active: false, + releaseKind: "cleared", + releasedBySession: "orch-lead@rig", + releaseIdentityProvenance: "transport:v1", + releaseReason: "atomic step completed", + }); + + store.create(humanInput({ + generationUuid: "gen-2", + direction: "authorize", + automaticReason: "disabled", + reason: "short-lived one-shot", + })); + now = new Date("2026-08-08T18:16:00.000Z"); + const expired = store.list({ sessionName: SEAT }).find((row) => row["generationUuid"] === "gen-2"); + expect(expired).toMatchObject({ active: false, releaseKind: "expired" }); + } finally { + db.close(); + } + }); + + it("refuses anonymous, reasonless, and blanket decisions loudly", 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, + }); + expect(() => store.create(humanInput({ actorSession: "" }))).toThrow(/actor|required|attribut/i); + expect(() => store.create(humanInput({ reason: " " }))).toThrow(/reason|required/i); + expect(() => store.create(humanInput({ + generationUuid: "gen-blanket", + direction: "authorize", + automaticReason: undefined, + reason: "ignore refusals", + }))).toThrow(/automatic reason|required|blanket/i); + } finally { + db.close(); + } + }); +}); + +const BASE_POLICY = { + enabled: true, + thresholdPercent: 80, + preCompactInstruction: "prepare", + compactInstruction: "", + messageInline: "", + messageFilePath: "", + postRestoreAuditInstruction: "audit", + authorizeTtlMinutes: 15, +}; + +function settings(policy: Record = BASE_POLICY) { + return { resolveClaudeCompactionPolicy: () => policy } as never; +} + +function transport() { + const send = vi.fn(async () => ({ ok: true as const })); + return { send, value: { send } as unknown as SessionTransport }; +} + +function decisionHarness(opts: { + hold?: (generation: string | null) => Record | null; + authorizationReason?: string; +} = {}) { + const findActiveHold = vi.fn((input: { liveGenerationUuid: string | null }) => + opts.hold?.(input.liveGenerationUuid) ?? null); + const findMatchingAuthorization = vi.fn((input: { automaticReason: string }) => + input.automaticReason === opts.authorizationReason + ? { decisionId: "authorize-1", automaticReason: input.automaticReason } + : null); + return { + findActiveHold, + findMatchingAuthorization, + observeHold: vi.fn(), + consumeAuthorizationForAttempt: vi.fn(() => true), + recordAuthorizationAttempt: vi.fn(), + }; +} + +const highInput = (usedPercentage = 90): EnforcerInput => ({ + sessionName: SEAT, + runtime: "claude-code", + usedPercentage, + transcriptPath: "/tmp/claude.jsonl", +}); + +describe("W4 ClaudeCompactionEnforcer decision consumption", () => { + it("a hold prevents firing and reports human_hold durably", async () => { + const tx = transport(); + const decisions = decisionHarness({ hold: () => ({ decisionId: "hold-1" }) }); + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + resolveOccupantGeneration: () => "gen-1", + decisionStore: decisions, + } as never); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "human_hold", + decisionId: "hold-1", + }); + expect(tx.send).not.toHaveBeenCalled(); + expect(decisions.observeHold).toHaveBeenCalledWith(expect.objectContaining({ + decisionId: "hold-1", + outcome: "human_hold", + })); + }); + + it("a null live generation fails closed on a hold, then known N+1 makes the N hold inert", async () => { + const tx = transport(); + let liveGeneration: string | null = null; + const decisions = decisionHarness({ + hold: (generation) => generation === null ? { decisionId: "hold-N" } : null, + }); + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + resolveOccupantGeneration: () => liveGeneration, + decisionStore: decisions, + } as never); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "human_hold", + decisionId: "hold-N", + }); + liveGeneration = "gen-N+1"; + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true }); + expect(tx.send).toHaveBeenCalledTimes(1); + }); + + it("matches a persisted generation-N hold and makes it inert for generation N+1", 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 hold = store.create(humanInput({ generationUuid: "gen-N" })); + const tx = transport(); + let liveGeneration = "gen-N"; + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + resolveOccupantGeneration: () => liveGeneration, + decisionStore: store, + } as never); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "human_hold", + decisionId: hold["decisionId"], + }); + expect(tx.send).not.toHaveBeenCalled(); + + liveGeneration = "gen-N+1"; + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true }); + expect(tx.send).toHaveBeenCalledTimes(1); + } finally { + db.close(); + } + }); + + it("consumes a persisted authorization only for matching known generation N and only once", 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 authorization = store.create(humanInput({ + generationUuid: "gen-N", + direction: "authorize", + automaticReason: "disabled", + reason: "lift disabled once for generation N", + })); + const row = () => store.list({ sessionName: SEAT }).find( + (candidate) => candidate["decisionId"] === authorization["decisionId"], + ); + const tx = transport(); + let liveGeneration: string | null = null; + const enforcer = new ClaudeCompactionEnforcer( + settings({ ...BASE_POLICY, enabled: false }), + tx.value, + { + resolveOccupantGeneration: () => liveGeneration, + decisionStore: store, + } as never, + ); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "disabled", + }); + expect(row()).toMatchObject({ active: true }); + expect(row()?.["consumedAt"] ?? null).toBeNull(); + expect(tx.send).not.toHaveBeenCalled(); + + liveGeneration = "gen-N+1"; + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "disabled", + }); + expect(row()).toMatchObject({ active: true }); + expect(row()?.["consumedAt"] ?? null).toBeNull(); + 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: false, + consumedByEnforcerKind: KIND, + liftedReason: "disabled", + attemptOutcome: "succeeded", + }); + expect(tx.send).toHaveBeenCalledTimes(1); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "disabled", + }); + expect(tx.send).toHaveBeenCalledTimes(1); + } finally { + db.close(); + } + }); + + it("lifts disabled once and reports decisionId + liftedReason on the triggered outcome", async () => { + const tx = transport(); + const decisions = decisionHarness({ authorizationReason: "disabled" }); + const enforcer = new ClaudeCompactionEnforcer( + settings({ ...BASE_POLICY, enabled: false }), + tx.value, + { + resolveOccupantGeneration: () => "gen-1", + decisionStore: decisions, + } as never, + ); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: true, + decisionId: "authorize-1", + liftedReason: "disabled", + }); + expect(tx.send).toHaveBeenCalledTimes(1); + expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1); + expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({ + decisionId: "authorize-1", + outcome: "succeeded", + })); + }); + + it("does not consume authorization when its reason is not the refusal that releases a send", async () => { + const tx = transport(); + const decisions = decisionHarness({ authorizationReason: "disabled" }); + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + resolveOccupantGeneration: () => "gen-1", + decisionStore: decisions, + } as never); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ triggered: true }); + expect(tx.send).toHaveBeenCalledTimes(1); + expect(decisions.consumeAuthorizationForAttempt).not.toHaveBeenCalled(); + }); + + it("lifts post_restore_cooldown for one send attempt", async () => { + const tx = transport(); + const decisions = decisionHarness({ authorizationReason: "post_restore_cooldown" }); + let now = 1_700_000_000_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + try { + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + dedupWindowMs: 0, + postCompactRestoreCooldownMs: 60_000, + openrigHome: "/tmp/openrig-w4", + resolveOccupantGeneration: () => "gen-1", + decisionStore: decisions, + } as never); + + await enforcer.maybeAutoCompact(highInput()); + now += 1_000; + await enforcer.maybeAutoCompact(highInput()); + for (let i = 0; i < 3; i += 1) { + now += 1_000; + await enforcer.maybeAutoCompact(highInput(20)); + } + 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(); + } + }); + + it("checks stale_generation authorization before deleting the queued stage", async () => { + const tx = transport(); + const decisions = decisionHarness({ authorizationReason: "stale_generation" }); + let generation = "gen-N"; + const enforcer = new ClaudeCompactionEnforcer(settings(), tx.value, { + dedupWindowMs: 0, + postCompactRestoreCooldownMs: 0, + openrigHome: "/tmp/openrig-w4", + resolveOccupantGeneration: () => generation, + decisionStore: decisions, + } as never); + + await enforcer.maybeAutoCompact(highInput()); + await enforcer.maybeAutoCompact(highInput()); + generation = "gen-N+1"; + await expect(enforcer.maybeAutoCompact(highInput(20))).resolves.toEqual({ + triggered: true, + decisionId: "authorize-1", + liftedReason: "stale_generation", + }); + expect(tx.send.mock.calls.at(-1)?.[1]).toContain("post-compaction turn boundary"); + 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" })); + const decisions = decisionHarness({ authorizationReason: "disabled" }); + const enforcer = new ClaudeCompactionEnforcer( + settings({ ...BASE_POLICY, enabled: false }), + { send } as unknown as SessionTransport, + { + resolveOccupantGeneration: () => "gen-1", + decisionStore: decisions, + } as never, + ); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "send_failed", + }); + expect(decisions.consumeAuthorizationForAttempt).toHaveBeenCalledTimes(1); + expect(decisions.recordAuthorizationAttempt).toHaveBeenCalledWith(expect.objectContaining({ + decisionId: "authorize-1", + outcome: "failed", + failureReason: "send_failed", + })); + }); + + it("records a thrown authorized send as failed and keeps the authorization one-shot", 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 authorization = store.create(humanInput({ + generationUuid: "gen-N", + direction: "authorize", + automaticReason: "disabled", + reason: "allow one attempted send", + })); + const send = vi.fn(async () => { + throw new Error("transport exploded"); + }); + const enforcer = new ClaudeCompactionEnforcer( + settings({ ...BASE_POLICY, enabled: false }), + { send } as unknown as SessionTransport, + { + resolveOccupantGeneration: () => "gen-N", + decisionStore: store, + } as never, + ); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "send_failed", + }); + expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({ + decisionId: authorization["decisionId"], + active: false, + consumedByEnforcerKind: KIND, + liftedReason: "disabled", + attemptOutcome: "failed", + attemptFailureReason: "transport exploded", + })); + + await expect(enforcer.maybeAutoCompact(highInput())).resolves.toEqual({ + triggered: false, + reason: "disabled", + }); + expect(send).toHaveBeenCalledTimes(1); + } finally { + db.close(); + } + }); +});