mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 13:56:40 -10:00
fix: enforce compaction final-boundary identity
This commit is contained in:
1 parent
07879b10b4
commit
89fd15bf59
9 files changed
+471
-28
No files matched your search
@@ -73,7 +73,7 @@ Keys:
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, compact_instruction,
|
||||
message_inline, message_file_path,
|
||||
authorize_ttl_minutes
|
||||
policies.claude_compaction.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
|
||||
|
||||
@@ -710,10 +710,13 @@ export class ClaudeCompactionEnforcer {
|
||||
*/
|
||||
async triggerManualCompact(
|
||||
input: EnforcerInput,
|
||||
opts: { operatorInitiated?: boolean } = {},
|
||||
opts: {
|
||||
operatorInitiated?: boolean;
|
||||
resolveCurrentSessionName?: (sessionName: string) => string | null;
|
||||
} = {},
|
||||
): Promise<ManualCompactionOutcome> {
|
||||
const liveGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
|
||||
const activeHold = this.findAndObserveHold(input.sessionName, liveGenerationUuid);
|
||||
const startingGenerationUuid = this.resolveOccupantGeneration?.(input.sessionName) ?? null;
|
||||
const activeHold = this.findAndObserveHold(input.sessionName, startingGenerationUuid);
|
||||
if (activeHold) {
|
||||
return {
|
||||
triggered: false,
|
||||
@@ -786,12 +789,63 @@ export class ClaudeCompactionEnforcer {
|
||||
// Phase 2 — WAIT for the prep turn to complete (seat idle), THEN send
|
||||
// /compact. `waitForIdleMs` makes the transport block on explicit idle
|
||||
// evidence before pasting /compact, guaranteeing prep-before-compact.
|
||||
const preCompactCheck = {
|
||||
reason: null as string | null,
|
||||
decisionId: undefined as string | undefined,
|
||||
};
|
||||
const compact = await this.sessionTransport.send(
|
||||
input.sessionName,
|
||||
buildCompactCommand(policy.compactInstruction),
|
||||
{ waitForIdleMs: this.manualPrepWaitMs },
|
||||
{
|
||||
waitForIdleMs: this.manualPrepWaitMs,
|
||||
beforeSend: () => {
|
||||
const currentSessionName = opts.resolveCurrentSessionName
|
||||
? opts.resolveCurrentSessionName(input.sessionName)
|
||||
: input.sessionName;
|
||||
if (!currentSessionName) {
|
||||
preCompactCheck.reason = "session_not_current";
|
||||
return {
|
||||
reason: preCompactCheck.reason,
|
||||
error: `Session '${input.sessionName}' is no longer current.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentGenerationUuid = this.resolveOccupantGeneration?.(currentSessionName) ?? null;
|
||||
const currentHold = this.findAndObserveHold(currentSessionName, currentGenerationUuid);
|
||||
if (currentHold) {
|
||||
preCompactCheck.reason = "human_hold";
|
||||
preCompactCheck.decisionId = currentHold.decisionId;
|
||||
return {
|
||||
reason: preCompactCheck.reason,
|
||||
error: `Session '${currentSessionName}' has an active human compaction hold.`,
|
||||
};
|
||||
}
|
||||
if (currentSessionName !== input.sessionName) {
|
||||
preCompactCheck.reason = "session_not_current";
|
||||
return {
|
||||
reason: preCompactCheck.reason,
|
||||
error: `Session '${input.sessionName}' is no longer current; current session is '${currentSessionName}'.`,
|
||||
};
|
||||
}
|
||||
if (currentGenerationUuid !== startingGenerationUuid) {
|
||||
preCompactCheck.reason = "occupant_generation_changed";
|
||||
return {
|
||||
reason: preCompactCheck.reason,
|
||||
error: `Session '${input.sessionName}' changed occupant generation during manual compaction preparation.`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!compact.ok) {
|
||||
if (preCompactCheck.reason) {
|
||||
return this.recordManualFailure(
|
||||
input.sessionName,
|
||||
preCompactCheck.reason,
|
||||
preCompactCheck.decisionId,
|
||||
);
|
||||
}
|
||||
return this.recordManualFailure(input.sessionName, compact.reason ?? "send_failed");
|
||||
}
|
||||
|
||||
@@ -849,9 +903,14 @@ export class ClaudeCompactionEnforcer {
|
||||
this.manualCompactionState.set(sessionName, { stage, reason, updatedAt: Date.now(), operatorInitiated });
|
||||
}
|
||||
|
||||
private recordManualFailure(sessionName: string, reason: string): ManualCompactionOutcome {
|
||||
private recordManualFailure(sessionName: string, reason: string, decisionId?: string): ManualCompactionOutcome {
|
||||
this.setManualStage(sessionName, "skipped-or-failed", reason);
|
||||
return { triggered: false, stage: "skipped-or-failed", reason };
|
||||
return {
|
||||
triggered: false,
|
||||
stage: "skipped-or-failed",
|
||||
reason,
|
||||
...(decisionId ? { decisionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private findAndObserveHold(
|
||||
|
||||
@@ -370,6 +370,11 @@ export interface SendOpts {
|
||||
verify?: boolean;
|
||||
force?: boolean;
|
||||
waitForIdleMs?: number;
|
||||
/** Final fail-loud check after any idle wait and immediately before delivery. */
|
||||
beforeSend?: () =>
|
||||
| { reason: string; error?: string }
|
||||
| null
|
||||
| Promise<{ reason: string; error?: string } | null>;
|
||||
// OPR.0.4.1.10 — interactive-prompt / permission guard.
|
||||
// `dangerouslyInteract` is the ONLY override of the prompt/permission guard (force does NOT bypass
|
||||
// it). It requires `reason` and writes an auditable `transport.prompt_override` record before the
|
||||
@@ -946,6 +951,31 @@ export class SessionTransport {
|
||||
text = appendDeliveredSegment(text, this.now().getTime() - Date.parse(opts.stampISO));
|
||||
}
|
||||
|
||||
if (opts?.beforeSend) {
|
||||
try {
|
||||
const refusal = await opts.beforeSend();
|
||||
if (refusal) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: refusal.reason,
|
||||
error: refusal.error,
|
||||
sent: false,
|
||||
...waitEvidence,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: "before_send_check_failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
sent: false,
|
||||
...waitEvidence,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send text (paste)
|
||||
const textResult = await this.runStage(
|
||||
"session_transport.send_text",
|
||||
|
||||
@@ -63,25 +63,12 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
}
|
||||
}
|
||||
|
||||
const currentSession = sessionRegistry.db.prepare(`
|
||||
WITH target AS (
|
||||
SELECT node_id
|
||||
FROM sessions
|
||||
WHERE session_name = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT session_name
|
||||
FROM sessions
|
||||
WHERE node_id = (SELECT node_id FROM target)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(sessionName) as { session_name: string } | undefined;
|
||||
if (currentSession && currentSession.session_name !== sessionName) {
|
||||
const currentSession = currentSessionName(sessionRegistry.db, sessionName);
|
||||
if (currentSession && currentSession !== sessionName) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "session_not_current",
|
||||
currentSession: currentSession.session_name,
|
||||
currentSession,
|
||||
}, 409);
|
||||
}
|
||||
|
||||
@@ -169,6 +156,15 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
return c.json({ ok: false, error: resolved.error }, status);
|
||||
}
|
||||
|
||||
const currentSession = currentSessionName(db, sessionName);
|
||||
if (currentSession && currentSession !== sessionName) {
|
||||
return c.json({
|
||||
ok: false,
|
||||
error: "session_not_current",
|
||||
currentSession,
|
||||
}, 409);
|
||||
}
|
||||
|
||||
// Resolve the DB node id + runtime for the latest session row.
|
||||
const row = db.prepare(`
|
||||
SELECT n.id AS node_id, n.runtime AS runtime
|
||||
@@ -201,7 +197,10 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
// This is the OPERATOR's manual-trigger verb (bearer-auth'd); the resulting sequence is
|
||||
// drain-exempt while auto-compaction is disabled. GHOST-STAGE fix (a) actor-gate: automation
|
||||
// paths that do NOT set this are NOT exempt, so they cannot launder a drain past the gate.
|
||||
{ operatorInitiated: true },
|
||||
{
|
||||
operatorInitiated: true,
|
||||
resolveCurrentSessionName: (candidate) => currentSessionName(db, candidate),
|
||||
},
|
||||
);
|
||||
|
||||
if (outcome.triggered) {
|
||||
@@ -222,6 +221,8 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
send_failed: 502,
|
||||
submit_failed: 502,
|
||||
human_hold: 409,
|
||||
session_not_current: 409,
|
||||
occupant_generation_changed: 409,
|
||||
};
|
||||
const status = (statusMap[outcome.reason] ?? 409) as 400 | 404 | 409 | 422 | 502 | 503;
|
||||
return c.json({
|
||||
@@ -249,6 +250,24 @@ export function compactionRoutes(opts?: { bearerToken?: string | null }): Hono {
|
||||
return router;
|
||||
}
|
||||
|
||||
function currentSessionName(db: Database.Database, sessionName: string): string | null {
|
||||
const row = db.prepare(`
|
||||
WITH target AS (
|
||||
SELECT node_id
|
||||
FROM sessions
|
||||
WHERE session_name = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT session_name
|
||||
FROM sessions
|
||||
WHERE node_id = (SELECT node_id FROM target)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`).get(sessionName) as { session_name: string } | undefined;
|
||||
return row?.session_name ?? null;
|
||||
}
|
||||
|
||||
function manualReasonMessage(sessionName: string, reason: string): string {
|
||||
switch (reason) {
|
||||
case "runtime_filter":
|
||||
@@ -265,6 +284,10 @@ function manualReasonMessage(sessionName: string, reason: string): string {
|
||||
return `Prep was sent to '${sessionName}' but it did not go idle in time, so /compact was NOT sent. Retry once the prep turn completes.`;
|
||||
case "human_hold":
|
||||
return `Refused: '${sessionName}' has an active human compaction hold.`;
|
||||
case "session_not_current":
|
||||
return `Refused: '${sessionName}' is no longer the current session for its node.`;
|
||||
case "occupant_generation_changed":
|
||||
return `Refused: '${sessionName}' changed occupant generation during compaction preparation.`;
|
||||
default:
|
||||
return `Manual compaction for '${sessionName}' did not complete (${reason}).`;
|
||||
}
|
||||
|
||||
@@ -911,7 +911,10 @@ describe("ClaudeCompactionEnforcer", () => {
|
||||
expect(send.mock.calls[0]![2]).toBeUndefined();
|
||||
// Phase 2 — /compact WITH the trust-bridge AND wait-for-idle (two-phase).
|
||||
expect(send.mock.calls[1]![1]).toContain("/compact In the continuity summary, preserve this trust-channel note");
|
||||
expect(send.mock.calls[1]![2]).toEqual({ waitForIdleMs: expect.any(Number) });
|
||||
expect(send.mock.calls[1]![2]).toEqual({
|
||||
waitForIdleMs: expect.any(Number),
|
||||
beforeSend: expect.any(Function),
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The SAME maybeAutoCompact back-half (single restore path) drains it.
|
||||
|
||||
@@ -495,6 +495,47 @@ describe("SessionTransport", () => {
|
||||
expect(callOrder).toEqual(["capture", "capture", "sendText", "sendKeys"]);
|
||||
});
|
||||
|
||||
it("send with wait-for-idle runs its final check after idle and can refuse before delivery", async () => {
|
||||
seedCanonicalRig();
|
||||
const callOrder: string[] = [];
|
||||
let captureCount = 0;
|
||||
const sendTextSpy = vi.fn(async () => {
|
||||
callOrder.push("sendText");
|
||||
return { ok: true as const };
|
||||
});
|
||||
const tmux = mockTmux({
|
||||
capturePaneContent: async () => {
|
||||
callOrder.push("capture");
|
||||
captureCount++;
|
||||
return captureCount === 1
|
||||
? "Working on task...\n⠋ Processing files\nesc to interrupt"
|
||||
: "Ready\n❯ ";
|
||||
},
|
||||
sendText: sendTextSpy,
|
||||
});
|
||||
const transport = createTransport(tmux, {
|
||||
sleep: async () => undefined,
|
||||
waitForIdlePollMs: 1,
|
||||
});
|
||||
|
||||
const result = await transport.send("dev-impl@my-rig", "hello", {
|
||||
waitForIdleMs: 50,
|
||||
beforeSend: async () => {
|
||||
callOrder.push("beforeSend");
|
||||
return { reason: "human_hold", error: "Compaction hold became active." };
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: "human_hold",
|
||||
sent: false,
|
||||
attempts: 2,
|
||||
});
|
||||
expect(callOrder).toEqual(["capture", "capture", "beforeSend"]);
|
||||
expect(sendTextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("send with wait-for-idle waits through current Claude thinking evidence and sends after idle", async () => {
|
||||
seedCanonicalRig();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
@@ -28,6 +28,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",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
for (const k of keys) {
|
||||
@@ -78,7 +79,7 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 2 V1 pre-release Item 1 (transcripts.lines / transcripts.poll_interval_seconds)
|
||||
// + 1 plugin-primitive Phase 3a slice 3.5 (runtime.codex.hooks_enabled)
|
||||
// + 1 V0.3.1 slice 05 (workspace.operator_seat_name)
|
||||
// + 7 slice 27 (policies.claude_compaction.*)
|
||||
// + 8 slice 27 + W4 (policies.claude_compaction.*)
|
||||
// + 3 OPR.0.3.4.9 (snapshots.periodic.*)
|
||||
// + 1 OPR.0.4.0.1 (ui.terminal.max_live_terminals)
|
||||
// + 2 OPR.0.4.6.MH1 (host.selected / host.name)
|
||||
@@ -88,14 +89,18 @@ describe("config routes (User Settings v0)", () => {
|
||||
// watchdog_keep_per_job / batch_size — the CLI-settable queue-retention knobs;
|
||||
// + retention.usage_samples_days, 51-08 A2)
|
||||
// + 2 OPR.0.5.1 W2c (policies.idle_gate_qitem.scan_interval_seconds /
|
||||
// active_wake_interval_seconds) → 52 total.
|
||||
expect(Object.keys(body.settings).length).toBe(52);
|
||||
// active_wake_interval_seconds) → 53 total.
|
||||
expect(Object.keys(body.settings).length).toBe(53);
|
||||
expect(body.settings["daemon.port"]?.source).toBe("default");
|
||||
expect(body.settings["ui.preview.refresh_interval_seconds"]?.value).toBe(3);
|
||||
expect(body.settings["ui.preview.max_pins"]?.value).toBe(4);
|
||||
expect(body.settings["ui.preview.default_lines"]?.value).toBe(50);
|
||||
expect(body.settings["recovery.auto_drive_provider_prompts"]?.value).toBe(false);
|
||||
expect(body.settings["recovery.provider_auth_env_allowlist"]?.value).toBe("");
|
||||
expect(body.settings["policies.claude_compaction.authorize_ttl_minutes"]).toMatchObject({
|
||||
value: 15,
|
||||
source: "default",
|
||||
});
|
||||
expect(body.settings["host.selected"]).toMatchObject({ value: "local", source: "default" });
|
||||
expect(body.settings["host.name"]).toMatchObject({ value: "localhost", source: "default" });
|
||||
expect(String(body.settings["workspace.dogfood_evidence_root"]?.value)).toMatch(/dogfood-evidence$/);
|
||||
|
||||
@@ -80,6 +80,74 @@ describe("W4 compaction-control routes", () => {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
|
||||
function makeBoundaryTransport(afterPrep: () => void) {
|
||||
const delivered: string[] = [];
|
||||
const send = vi.fn(async (
|
||||
_sessionName: string,
|
||||
message: string,
|
||||
opts?: {
|
||||
beforeSend?: () =>
|
||||
| { reason: string; error?: string }
|
||||
| null
|
||||
| Promise<{ reason: string; error?: string } | null>;
|
||||
},
|
||||
) => {
|
||||
if (send.mock.calls.length === 1) {
|
||||
delivered.push(message);
|
||||
afterPrep();
|
||||
return { ok: true as const };
|
||||
}
|
||||
const refusal = await opts?.beforeSend?.();
|
||||
if (refusal) return { ok: false as const, sessionName: SEAT, sent: false, ...refusal };
|
||||
delivered.push(message);
|
||||
return { ok: true as const };
|
||||
});
|
||||
return {
|
||||
delivered,
|
||||
send,
|
||||
transport: {
|
||||
send,
|
||||
resolveSessions: vi.fn(async () => ({ ok: true as const, sessions: [SEAT] })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installManualHarness(store: EnforcerDecisionStore, transport: Record<string, unknown>): void {
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
{
|
||||
resolveClaudeCompactionPolicy: () => ({
|
||||
enabled: true,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: "prepare",
|
||||
compactInstruction: "",
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
postRestoreAuditInstruction: "audit",
|
||||
authorizeTtlMinutes: 15,
|
||||
}),
|
||||
} as never,
|
||||
transport as never,
|
||||
{
|
||||
resolveOccupantGeneration: (sessionName: string) =>
|
||||
sessionRegistry.currentOccupantGenerationForSession(sessionName),
|
||||
decisionStore: store,
|
||||
},
|
||||
);
|
||||
manualDeps = {
|
||||
enforcer,
|
||||
transport,
|
||||
usageStore: {
|
||||
getForNode: () => ({
|
||||
availability: "known",
|
||||
usedPercentage: 90,
|
||||
transcriptPath: "/tmp/claude.jsonl",
|
||||
sessionId: "claude-session",
|
||||
}),
|
||||
},
|
||||
};
|
||||
decisions = store as never;
|
||||
}
|
||||
|
||||
it("refuses an unattributable hold before writing any record", async () => {
|
||||
const response = await request("/api/compaction/control", {
|
||||
session: SEAT,
|
||||
@@ -285,6 +353,168 @@ describe("W4 compaction-control routes", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("the public manual trigger rejects a historical alias before it can miss the current alias hold", async () => {
|
||||
const currentSeat = "claude-seat-next@rig";
|
||||
sessionRegistry.registerSession(nodeId, currentSeat);
|
||||
const currentGeneration = sessionRegistry.currentOccupantGenerationForSession(currentSeat)!;
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const hold = store.create({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: currentSeat,
|
||||
generationUuid: currentGeneration,
|
||||
direction: "hold",
|
||||
automaticReason: null,
|
||||
reason: "finish the current atomic action",
|
||||
actorSession: IDENTITY,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
const send = vi.fn(async () => ({ ok: true as const }));
|
||||
const transport = {
|
||||
send,
|
||||
resolveSessions: vi.fn(async () => ({ ok: true as const, sessions: [SEAT] })),
|
||||
};
|
||||
const enforcer = new ClaudeCompactionEnforcer(
|
||||
{
|
||||
resolveClaudeCompactionPolicy: () => ({
|
||||
enabled: true,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: "prepare",
|
||||
compactInstruction: "",
|
||||
messageInline: "",
|
||||
messageFilePath: "",
|
||||
postRestoreAuditInstruction: "audit",
|
||||
authorizeTtlMinutes: 15,
|
||||
}),
|
||||
} as never,
|
||||
transport as never,
|
||||
{
|
||||
resolveOccupantGeneration: (sessionName: string) =>
|
||||
sessionRegistry.currentOccupantGenerationForSession(sessionName),
|
||||
decisionStore: store,
|
||||
},
|
||||
);
|
||||
manualDeps = {
|
||||
enforcer,
|
||||
transport,
|
||||
usageStore: {
|
||||
getForNode: () => ({
|
||||
availability: "known",
|
||||
usedPercentage: 90,
|
||||
transcriptPath: "/tmp/claude.jsonl",
|
||||
sessionId: "claude-session",
|
||||
}),
|
||||
},
|
||||
};
|
||||
decisions = store as never;
|
||||
|
||||
const stale = await request("/api/compaction/trigger", { session: SEAT });
|
||||
expect(stale.status).toBe(409);
|
||||
expect(await stale.json()).toMatchObject({
|
||||
ok: false,
|
||||
error: "session_not_current",
|
||||
currentSession: currentSeat,
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
|
||||
decisionId: hold.decisionId,
|
||||
active: true,
|
||||
lastObservedAt: null,
|
||||
}));
|
||||
|
||||
const current = await request("/api/compaction/trigger", { session: currentSeat });
|
||||
expect(current.status).toBe(409);
|
||||
expect(await current.json()).toMatchObject({
|
||||
ok: false,
|
||||
reason: "human_hold",
|
||||
decisionId: hold.decisionId,
|
||||
});
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
|
||||
decisionId: hold.decisionId,
|
||||
lastObservedOutcome: "human_hold",
|
||||
}));
|
||||
});
|
||||
|
||||
it("the public manual trigger observes a new-current-alias hold activated during preparation", async () => {
|
||||
const currentSeat = "claude-seat-next@rig";
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
let hold: ReturnType<EnforcerDecisionStore["create"]> | null = null;
|
||||
const tx = makeBoundaryTransport(() => {
|
||||
sessionRegistry.registerSession(nodeId, currentSeat, "handover");
|
||||
hold = store.create({
|
||||
enforcerKind: "claude_compaction",
|
||||
sessionName: currentSeat,
|
||||
generationUuid: sessionRegistry.currentOccupantGenerationForSession(currentSeat)!,
|
||||
direction: "hold",
|
||||
automaticReason: null,
|
||||
reason: "hold the new occupant",
|
||||
actorSession: IDENTITY,
|
||||
identityProvenance: "transport:v1",
|
||||
});
|
||||
});
|
||||
installManualHarness(store, tx.transport);
|
||||
|
||||
const response = await request("/api/compaction/trigger", { session: SEAT });
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
reason: "human_hold",
|
||||
decisionId: hold?.decisionId,
|
||||
});
|
||||
expect(tx.delivered).toHaveLength(1);
|
||||
expect(tx.delivered[0]).toContain("automatic compaction preparation");
|
||||
expect(store.list({ sessionName: currentSeat })).toContainEqual(expect.objectContaining({
|
||||
decisionId: hold?.decisionId,
|
||||
lastObservedOutcome: "human_hold",
|
||||
lastObservedAt: "2026-08-08T18:00:00.000Z",
|
||||
}));
|
||||
});
|
||||
|
||||
it("the public manual trigger refuses an alias turnover without a hold at the final boundary", async () => {
|
||||
const currentSeat = "claude-seat-next@rig";
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const tx = makeBoundaryTransport(() => {
|
||||
sessionRegistry.registerSession(nodeId, currentSeat, "handover");
|
||||
});
|
||||
installManualHarness(store, tx.transport);
|
||||
|
||||
const response = await request("/api/compaction/trigger", { session: SEAT });
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
reason: "session_not_current",
|
||||
});
|
||||
expect(tx.delivered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("the public manual trigger refuses same-alias occupant-tenure drift at the final boundary", async () => {
|
||||
const store = new EnforcerDecisionStore(db, {
|
||||
now: () => new Date("2026-08-08T18:00:00.000Z"),
|
||||
authorizeTtlMinutes: () => 15,
|
||||
});
|
||||
const tx = makeBoundaryTransport(() => {
|
||||
sessionRegistry.mintOccupantTenure(nodeId, "handover");
|
||||
});
|
||||
installManualHarness(store, tx.transport);
|
||||
|
||||
const response = await request("/api/compaction/trigger", { session: SEAT });
|
||||
expect(response.status).toBe(409);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: false,
|
||||
reason: "occupant_generation_changed",
|
||||
});
|
||||
expect(tx.delivered).toHaveLength(1);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -394,6 +394,58 @@ describe("W4 ClaudeCompactionEnforcer decision consumption", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rechecks the real hold store after manual preparation and before the final action", 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,
|
||||
});
|
||||
let hold: Record<string, unknown> | null = null;
|
||||
const delivered: string[] = [];
|
||||
const send = vi.fn(async (
|
||||
_sessionName: string,
|
||||
message: string,
|
||||
opts?: { beforeSend?: () => Promise<{ reason: string; error?: string } | null> },
|
||||
) => {
|
||||
if (send.mock.calls.length === 1) {
|
||||
hold = store.create(humanInput({
|
||||
generationUuid: "gen-N",
|
||||
reason: "hold activated during manual preparation",
|
||||
}));
|
||||
}
|
||||
const refusal = await opts?.beforeSend?.();
|
||||
if (refusal) return { ok: false as const, sessionName: SEAT, sent: false, ...refusal };
|
||||
delivered.push(message);
|
||||
return { ok: true as const };
|
||||
});
|
||||
const enforcer = new ClaudeCompactionEnforcer(settings(), { send } as never, {
|
||||
resolveOccupantGeneration: () => "gen-N",
|
||||
decisionStore: store,
|
||||
} as never);
|
||||
|
||||
await expect(enforcer.triggerManualCompact(highInput(), { operatorInitiated: true })).resolves.toEqual({
|
||||
triggered: false,
|
||||
stage: "skipped-or-failed",
|
||||
reason: "human_hold",
|
||||
decisionId: expect.any(String),
|
||||
});
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0]).toContain("automatic compaction preparation");
|
||||
expect(hold).not.toBeNull();
|
||||
expect(store.list({ sessionName: SEAT })).toContainEqual(expect.objectContaining({
|
||||
decisionId: hold?.["decisionId"],
|
||||
active: true,
|
||||
lastObservedOutcome: "human_hold",
|
||||
lastObservedAt: "2026-08-08T18:00:00.000Z",
|
||||
}));
|
||||
} 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();
|
||||
|
||||
Reference in new issue
Block a user