mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
feat(daemon): add policies.claude_compaction.preparation_root for restore maps (#1585)
Managed Claude compaction wrote each restore map under the seat's launch folder whenever the daemon's own write check passed. A seat whose Claude permissions deny edits there couldn't write its map, so preparation ended `preparation_incomplete`, and the only way out was `--skip-map` or many generated permission rules. Now an operator can set an absolute, instance-wide `policies.claude_compaction.preparation_root` (or its environment variable). Manual and automatic compaction then use `<root>/<session>/<attempt>/RESTORE-MAP.md`, chosen once per attempt and kept through preparation, publication and restore, even if the setting changes mid-attempt. The operator grants Claude edit access to that root; OpenRig doesn't change Claude's permissions. With the setting unset, paths are exactly as before, including the instance-home fallback. A configured root that can't be created or written, or that already holds a different `.gitignore`, stops that attempt with `preparation_directory_unavailable`, shown in `rig compact --state`, instead of silently choosing another folder. `--skip-map` still works. Limits: use a dedicated root. OpenRig writes a `.gitignore` of `*` into the root itself, so a root inside a repository folder without its own `.gitignore` hides new files there. No live Claude compaction or permission session was run; the tests use real temporary files with synthetic delivery. Thanks to @Aschenbath for the PR, and to @aanestesia for the issue and for measuring what the workaround cost. Cost: source +59/-21 (4 files), docs +43/-1 (2 files), tests +272/-8 (7 files). One new failure reason, `preparation_directory_unavailable`, reached only with the new setting.
This commit is contained in:
1 parent
f9f843bf62
commit
65e49b5e1e
13 files changed
+374
-30
No files matched your search
@@ -284,7 +284,9 @@ with. Claude Code before 2.1.290 ignores the key, Claude's managed settings take
|
||||
2. The agent writes `RESTORE-MAP.md.tmp` with its file-edit tool, completion marker last.
|
||||
Later ticks return `preparation_pending` until that exact map is complete. The map path is under the seat's working directory
|
||||
(`.openrig/compaction/preparation/<session>/<attempt>/`, with a `.gitignore` of `*`) when
|
||||
that is writable, otherwise under `compaction/preparation/` in the OpenRig home.
|
||||
that is writable, otherwise under `compaction/preparation/` in the OpenRig home. An explicit
|
||||
`policies.claude_compaction.preparation_root` instead places maps at `<root>/<session>/<attempt>/`
|
||||
for both manual and automatic attempts; failure to prepare that root stops the attempt without fallback.
|
||||
3. The daemon atomically renames the completed temp file to `RESTORE-MAP.md` in its existing
|
||||
final send checks, after rechecking the attempt and occupant. An already-complete final map
|
||||
remains compatible. Only then is `/compact` sent, with the final map path appended to the
|
||||
|
||||
@@ -523,6 +523,46 @@ remain shared.
|
||||
|
||||
**Terminal seats** (`builtin:terminal`) get no agent or profile layers and no startup proof.
|
||||
|
||||
### Claude compaction preparation directory
|
||||
|
||||
Managed Claude compaction asks the seat to write a restore map before `/compact`. By default,
|
||||
OpenRig uses `<cwd>/.openrig/compaction/preparation/`, falling back to its instance home when
|
||||
that directory cannot be prepared. A filesystem write check cannot establish whether Claude's
|
||||
file-edit permissions allow the seat to write there.
|
||||
|
||||
If the launch workspace is denied by Claude's permissions, select an absolute preparation root:
|
||||
|
||||
```sh
|
||||
rig config set policies.claude_compaction.preparation_root /absolute/path/to/restore-maps
|
||||
```
|
||||
|
||||
Grant the seat file-edit access to that root in its Claude configuration. OpenRig does not change
|
||||
Claude permissions; choose a directory outside any matching deny rule. The corresponding environment
|
||||
variable is `OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT`.
|
||||
|
||||
New manual and automatic attempts use `<root>/<session>/<attempt-id>/RESTORE-MAP.md`; an in-flight
|
||||
attempt keeps its original path through the post-compaction restore request. The root gets an
|
||||
exclusive `.gitignore` containing `*`, and the daemon publishes the marked `.tmp` file with an
|
||||
atomic rename. Use a dedicated root: a conflicting
|
||||
ignore file or an unwritable configured directory stops preparation with
|
||||
`preparation_directory_unavailable`, visible in `rig compact <session> --state`, without choosing
|
||||
another location. Fix the directory and explicitly retry. `--skip-map` still skips the map once.
|
||||
|
||||
Reset with `rig config reset policies.claude_compaction.preparation_root` to restore the default.
|
||||
The setting applies to new attempts without restarting the daemon.
|
||||
|
||||
The managed `/compact` command keeps its 600-character budget. When the selected map path or
|
||||
the attempt's compaction instruction does not fit, the existing restore request carries it;
|
||||
it never substitutes a newer preparation root or instruction. An explicitly selected root's
|
||||
published map is also named in that request when the path fit in `/compact`. The default root
|
||||
keeps the existing command/restore handoff. Under `--skip-map`, restore still recognizes an
|
||||
optional map completed at that attempt's path, or says that no map was required.
|
||||
|
||||
The compaction preparation and command-submission tests cover manual and automatic attempts,
|
||||
long paths and instructions, settings changed during preparation, directory failures and
|
||||
late maps after cancellation or expiry. They use synthetic delivery and filesystem fixtures;
|
||||
they do not prove a live Claude session's permission choices or compaction behavior.
|
||||
|
||||
### Codex compaction and sizing
|
||||
|
||||
OpenRig's managed compaction is for Claude Code seats only, by design. That covers `rig compact`, the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFileSync, mkdirSync, unlinkSync, existsSync } from "node:fs";
|
||||
import { writeTextAtomically } from "./atomic-text-write.js";
|
||||
import { join, dirname, resolve } from "node:path";
|
||||
import { join, dirname, resolve, isAbsolute } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
getDefaultOpenRigPath,
|
||||
@@ -136,6 +136,7 @@ export interface RiggedConfig {
|
||||
// extra instruction file path for mission-specific reading lists.
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
preparationRoot: string;
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
preCompactInstruction: string;
|
||||
@@ -317,6 +318,7 @@ const DEFAULTS = {
|
||||
// Slice 27 — opt-in default-off. Threshold default 80% per spec.
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
preparationRoot: "",
|
||||
enabled: false,
|
||||
thresholdPercent: 80,
|
||||
preCompactInstruction: DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION,
|
||||
@@ -459,7 +461,8 @@ export const VALID_KEYS = [
|
||||
"runtime.codex.hooks_enabled",
|
||||
"runtime.readiness_timeout_seconds",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
|
||||
// 7 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
|
||||
// 8 ConfigStore keys (lockstep with daemon SETTINGS_VALID_KEYS).
|
||||
"policies.claude_compaction.preparation_root",
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
@@ -575,6 +578,7 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"runtime.readiness_timeout_seconds": { primary: "OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS" },
|
||||
// Slice 27 — Claude auto-compaction policy. OPENRIG_X primary only
|
||||
// (net-new keys, no legacy).
|
||||
"policies.claude_compaction.preparation_root": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT" },
|
||||
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
|
||||
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
|
||||
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
|
||||
@@ -666,6 +670,7 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
|
||||
"runtime.readiness_timeout_seconds": ["runtime", "readinessTimeoutSeconds"],
|
||||
"policies.claude_compaction.preparation_root": ["policies", "claudeCompaction", "preparationRoot"],
|
||||
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
|
||||
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
|
||||
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
|
||||
@@ -963,6 +968,11 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
// e.g. 50 = compact earlier; range is 1-100 inclusive. A value of 0
|
||||
// would fire /compact on every poll tick (catastrophic without
|
||||
// default-off as backstop).
|
||||
"policies.claude_compaction.preparation_root": (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
@@ -1224,6 +1234,7 @@ export class ConfigStore {
|
||||
},
|
||||
policies: {
|
||||
claudeCompaction: {
|
||||
preparationRoot: v("policies.claude_compaction.preparation_root") as string,
|
||||
enabled: v("policies.claude_compaction.enabled") as boolean,
|
||||
thresholdPercent: v("policies.claude_compaction.threshold_percent") as number,
|
||||
preCompactInstruction: v("policies.claude_compaction.pre_compact_instruction") as string,
|
||||
|
||||
@@ -65,6 +65,7 @@ function clearEnv(): () => void {
|
||||
"OPENRIG_FEED_SUBSCRIPTIONS_AUDIT_LOG",
|
||||
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
|
||||
"OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
|
||||
@@ -154,6 +155,7 @@ describe("ConfigStore — extended namespaces (User Settings v0)", () => {
|
||||
"runtime.codex.hooks_enabled",
|
||||
"runtime.readiness_timeout_seconds",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
|
||||
"policies.claude_compaction.preparation_root",
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
@@ -1240,3 +1242,39 @@ describe("ConfigStore — GHOST-STAGE (d): write-canonical + verify-readback", (
|
||||
expect((JSON.parse(readFileSync(join(home, "config.json"), "utf-8")) as { daemon?: { port?: number } }).daemon?.port).toBe(9191);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("compaction preparation root", () => {
|
||||
let tmpDir: string, configPath: string;
|
||||
let restoreEnv: () => void;
|
||||
beforeEach(() => {
|
||||
restoreEnv = clearEnv();
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "preparation-root-settings-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
});
|
||||
afterEach(() => { restoreEnv(); rmSync(tmpDir, { recursive: true, force: true }); });
|
||||
const key = "policies.claude_compaction.preparation_root";
|
||||
it("resolves file/env overrides consistently and resets to workspace-first preparation", () => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const root = join(tmpDir, "maps with spaces");
|
||||
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
|
||||
store.set(key, root);
|
||||
expect(store.resolveWithSource(key)).toMatchObject({ value: root, source: "file" });
|
||||
expect(new ConfigStore(configPath).resolve().policies.claudeCompaction.preparationRoot).toBe(root);
|
||||
process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT = join(root, "override");
|
||||
try {
|
||||
expect(store.resolveWithSource(key)).toMatchObject({ value: join(root, "override"), source: "env" });
|
||||
} finally { delete process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT; }
|
||||
store.reset(key);
|
||||
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
|
||||
store.set(key, "");
|
||||
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe("");
|
||||
});
|
||||
it.each(["relative/maps", "~/maps", " /maps", "/maps\0invalid"])("rejects invalid root %j without replacing valid settings", (value) => {
|
||||
const store = new ConfigStore(configPath);
|
||||
const root = join(tmpDir, "maps");
|
||||
store.set(key, root);
|
||||
expect(() => store.set(key, value)).toThrow(/absolute path/);
|
||||
expect(store.resolve().policies.claudeCompaction.preparationRoot).toBe(root);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export type EnforcerSkipReason =
|
||||
| "preparation_pending"
|
||||
| "preparation_incomplete"
|
||||
| "preparation_stopped"
|
||||
| "preparation_directory_unavailable"
|
||||
| "occupant_generation_unavailable"
|
||||
| "no_pending_stage"
|
||||
| "waiting_for_idle"
|
||||
@@ -363,7 +364,8 @@ export function buildPostCompactRestorePrompt(input: {
|
||||
// refused at the source. Tell the seat NOT to seek it out (it is not its state).
|
||||
pieces.push("A post-compaction instruction file declaring a DIFFERENT seat was present and has been IGNORED — it is not yours; do NOT read or follow it. Rely on the per-seat marker and the JSONL transcript for restore.");
|
||||
}
|
||||
pieces.push("Load/read the claude-compaction-restore skill, follow the marker's restoreInstruction and postCompactInstruction when present, read your newest restore map and use the restore packet as a lookup, then reply with: restored from packet at <path>; resumed at step <X>.");
|
||||
const restoreMap = input.attemptMapPath ? "this preparation attempt's restore map" : "your newest restore map";
|
||||
pieces.push(`Load/read the claude-compaction-restore skill, follow the marker's restoreInstruction and postCompactInstruction when present, read ${restoreMap} and use the restore packet as a lookup, then reply with: restored from packet at <path>; resumed at step <X>.`);
|
||||
pieces.push(`During this restore, read ${path.join(input.openrigHome, "plugins", "openrig-core", "skills", "refocusing", "SKILL.md")} and consume the current topology and work traces delivered with this restore request, if present; do not rerun Python just to duplicate a delivered trace. Missing trace delivery is a named gap. Read required notes and full sources with the native file-read tool; a pointer, compact summary or truncated extract is not a full source read. Name any missing source, the current user outcome, and your next action from the files you actually read, not from the compact summary.`);
|
||||
return pieces.join(" ");
|
||||
}
|
||||
@@ -421,6 +423,8 @@ export interface PreparationState {
|
||||
interface PreparationAttempt extends PreparationState {
|
||||
sends: number;
|
||||
policyWasEnabled: boolean;
|
||||
/** The explicit preparation root selected when this attempt began; null keeps the default flow. */
|
||||
preparationRoot: string | null;
|
||||
controller: AbortController;
|
||||
/** The policy's compaction instruction when the attempt began; the /compact command uses this one. */
|
||||
compactInstruction: string;
|
||||
@@ -795,7 +799,7 @@ export class ClaudeCompactionEnforcer {
|
||||
if (attempt?.status === "stopped") return { triggered: false, reason: "preparation_stopped" };
|
||||
if (!attempt) {
|
||||
attempt = this.beginPreparation(input, "automatic");
|
||||
if (attempt.status === "stopped") return { triggered: false, reason: "occupant_generation_unavailable" };
|
||||
if (attempt.status === "stopped") return { triggered: false, reason: attempt.reason === "preparation_directory_unavailable" ? attempt.reason : "occupant_generation_unavailable" };
|
||||
}
|
||||
if (attempt.status === "sending") {
|
||||
await this.deliverPreparation(input, attempt);
|
||||
@@ -850,7 +854,7 @@ export class ClaudeCompactionEnforcer {
|
||||
// The preparation prompt cannot be written to such a seat either, so the whole attempt is refused up front.
|
||||
if (this.paneUnknownProtected(input.sessionName)) return this.recordManualFailure(input.sessionName, "pane_unknown_protected");
|
||||
const attempt = this.beginPreparation(input, "manual", opts.skipMap === true);
|
||||
if (attempt.status === "stopped") return this.recordManualFailure(input.sessionName, "occupant_generation_unavailable");
|
||||
if (attempt.status === "stopped") return this.recordManualFailure(input.sessionName, attempt.reason ?? "occupant_generation_unavailable");
|
||||
const failed = (reason: string): ManualCompactionOutcome => {
|
||||
// A cancelled request may settle after an explicit retry. Its receipt must
|
||||
// not stop or overwrite the successor attempt's state.
|
||||
@@ -896,26 +900,31 @@ export class ClaudeCompactionEnforcer {
|
||||
|
||||
private beginPreparation(input: EnforcerInput, mode: "automatic" | "manual", skipMap = false): PreparationAttempt {
|
||||
const attemptId = randomUUID();
|
||||
const startedAt = this.now();
|
||||
const policy = this.settingsStore.resolveClaudeCompactionPolicy();
|
||||
const preparationRoot = policy.preparationRoot || null;
|
||||
const fallbackMapPath = path.join(this.openrigHome, "compaction", "preparation", sanitizeSessionKey(input.sessionName), attemptId, "RESTORE-MAP.md");
|
||||
const occupantGeneration = this.resolveOccupantGeneration?.(input.sessionName)
|
||||
?? this.sessionTransport.deliveryGuard?.maybeTarget(input.sessionName)?.occupant ?? null;
|
||||
const attempt: PreparationAttempt = {
|
||||
attemptId, occupantGeneration, mode, status: "sending", delivery: "pending", compact: "not_sent", sends: 0,
|
||||
deadlineAt: mode === "manual" ? this.now() + this.manualPrepWaitMs : null,
|
||||
mapPath: fallbackMapPath,
|
||||
deadlineAt: mode === "manual" ? startedAt + this.manualPrepWaitMs : null,
|
||||
mapPath: preparationRoot ? path.join(preparationRoot, sanitizeSessionKey(input.sessionName), attemptId, "RESTORE-MAP.md") : fallbackMapPath,
|
||||
marker: `<!-- openrig-compaction-complete ${JSON.stringify({ attemptId, session: input.sessionName, occupantGeneration })} -->`,
|
||||
policyWasEnabled: this.settingsStore.resolveClaudeCompactionPolicy().enabled,
|
||||
policyWasEnabled: policy.enabled,
|
||||
preparationRoot,
|
||||
controller: new AbortController(),
|
||||
compactInstruction: this.settingsStore.resolveClaudeCompactionPolicy().compactInstruction ?? "",
|
||||
compactInstruction: policy.compactInstruction ?? "",
|
||||
};
|
||||
this.pendingPreCompactPrep.set(input.sessionName, attempt);
|
||||
// A map cannot satisfy an unknown occupant. Keep the failed attempt visible and
|
||||
// disarmed; explicit skip-map still bypasses only the artifact prerequisite.
|
||||
if (occupantGeneration === null && !skipMap) this.stopPreparation(input.sessionName, "occupant_generation_unavailable");
|
||||
if (attempt.status !== "stopped" && input.cwd && path.isAbsolute(input.cwd)) {
|
||||
// Keep writes inside Claude's existing edit workspace, without adding permissions.
|
||||
// This folder holds private working context; ignore its entire contents in Git.
|
||||
const root = path.join(input.cwd, ".openrig", "compaction");
|
||||
if (skipMap && preparationRoot) return attempt;
|
||||
if (attempt.status !== "stopped" && (preparationRoot || (input.cwd && path.isAbsolute(input.cwd)))) {
|
||||
// An explicit root can be outside a workspace denied by Claude file permissions.
|
||||
// The daemon cannot prove those permissions; the operator grants access to this root.
|
||||
const root = preparationRoot ?? path.join(input.cwd!, ".openrig", "compaction");
|
||||
try {
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const ignorePath = path.join(root, ".gitignore");
|
||||
@@ -934,11 +943,15 @@ export class ClaudeCompactionEnforcer {
|
||||
}
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
const parent = path.join(root, "preparation", sanitizeSessionKey(input.sessionName), attemptId);
|
||||
const parent = path.join(preparationRoot ?? path.join(root, "preparation"), sanitizeSessionKey(input.sessionName), attemptId);
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
fs.accessSync(parent, fs.constants.W_OK | fs.constants.X_OK);
|
||||
attempt.mapPath = path.join(parent, "RESTORE-MAP.md");
|
||||
} catch { /* Preserve the existing instance-home path for legacy or unwritable workspaces. */ }
|
||||
} catch {
|
||||
// An explicit location is a permission choice: never silently move it back into
|
||||
// the denied workspace or instance home. Keep the selected path visible in --state.
|
||||
if (preparationRoot) this.stopPreparation(input.sessionName, "preparation_directory_unavailable");
|
||||
}
|
||||
}
|
||||
if (attempt.status !== "stopped" && attempt.mapPath === fallbackMapPath) {
|
||||
// The cwd path already creates its parent. Prepare the legacy fallback too;
|
||||
@@ -1048,10 +1061,11 @@ export class ClaudeCompactionEnforcer {
|
||||
}
|
||||
attempt.compact = "submitted_unconfirmed";
|
||||
if (attempt.controller.signal.aborted) return { ok: false, reason: attempt.reason };
|
||||
// Whatever the command could not carry, the restore request carries instead.
|
||||
if (skipMap || !composed.mapPathInCommand || !composed.instructionInCommand) {
|
||||
// Whatever the command could not carry, the restore request carries instead. An explicitly
|
||||
// selected preparation root also keeps its exact map in that request, even when the command fit it.
|
||||
if (skipMap || attempt.preparationRoot !== null || !composed.mapPathInCommand || !composed.instructionInCommand) {
|
||||
this.restoreCarry.set(input.sessionName, {
|
||||
mapPath: !skipMap && !composed.mapPathInCommand ? attempt.mapPath : null,
|
||||
mapPath: !skipMap && (attempt.preparationRoot !== null || !composed.mapPathInCommand) ? attempt.mapPath : null,
|
||||
compactInstruction: composed.instructionInCommand ? null : attempt.compactInstruction,
|
||||
...(skipMap ? { skipMap: { mapPath: attempt.mapPath, marker: attempt.marker } } : {}),
|
||||
});
|
||||
@@ -1123,7 +1137,8 @@ export class ClaudeCompactionEnforcer {
|
||||
this.reconcilePreparations();
|
||||
const attempt = this.pendingPreCompactPrep.get(session);
|
||||
if (!attempt) return null;
|
||||
const { controller: _controller, sends: _sends, policyWasEnabled: _enabled, compactInstruction: _instruction, stoppedAt: _stoppedAt, cancelled: _cancelled, ...state } = attempt;
|
||||
const { controller: _controller, sends: _sends, policyWasEnabled: _enabled, preparationRoot: _preparationRoot,
|
||||
compactInstruction: _instruction, stoppedAt: _stoppedAt, cancelled: _cancelled, ...state } = attempt;
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ interface BrowserSource { id: string; state: State; path: string | null; detail:
|
||||
/** Names are presentation policy, never a second setting registry or resolver. */
|
||||
const PATHS = new Set(["db.path", "transcripts.path", "workspace.root", "workspace.slices_root",
|
||||
"workspace.steering_path", "workspace.specs_root", "workspace.projects_root", "workspace.catalog_path",
|
||||
"topology.root", "context.root", "skills.root", "policies.claude_compaction.message_file_path"]);
|
||||
"topology.root", "context.root", "skills.root", "policies.claude_compaction.message_file_path", "policies.claude_compaction.preparation_root"]);
|
||||
const IDENTITIES = new Set(["daemon.host", "host.name", "host.selected", "context.system_world",
|
||||
"ui.timezone", "agents.advisor_session", "agents.operator_session", "workspace.operator_seat_name",
|
||||
"workflow.exception_routing", "policies.idle_gate_qitem.auto_register"]);
|
||||
|
||||
@@ -151,7 +151,7 @@ export const SETTINGS_VALID_KEYS = [
|
||||
"runtime.codex.hooks_enabled",
|
||||
"runtime.readiness_timeout_seconds",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10:
|
||||
// 7 keys (lockstep with cli/src/config-store.ts VALID_KEYS).
|
||||
// 8 keys (lockstep with cli/src/config-store.ts VALID_KEYS).
|
||||
// Opt-in default-off; daemon ContextMonitor reads `enabled` +
|
||||
// `threshold_percent` to decide when to send pre-compact prep +
|
||||
// /compact. The daemon wraps `pre_compact_instruction` with usage
|
||||
@@ -159,6 +159,7 @@ export const SETTINGS_VALID_KEYS = [
|
||||
// the actual compaction phase, uses `message_inline` +
|
||||
// `message_file_path` for post-compaction restore guidance, then
|
||||
// wraps `post_restore_audit_instruction` as the read-depth nudge.
|
||||
"policies.claude_compaction.preparation_root",
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
@@ -287,6 +288,7 @@ const ENV_MAP: Record<SettingsValidKey, { primary: string; legacy?: string }> =
|
||||
"runtime.readiness_timeout_seconds": { primary: "OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS" },
|
||||
// Slice 27 — Claude auto-compaction policy. Net-new keys; OPENRIG_X
|
||||
// primary only.
|
||||
"policies.claude_compaction.preparation_root": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT" },
|
||||
"policies.claude_compaction.enabled": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED" },
|
||||
"policies.claude_compaction.threshold_percent": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT" },
|
||||
"policies.claude_compaction.pre_compact_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION" },
|
||||
@@ -377,6 +379,7 @@ const KEY_TO_PATH: Record<SettingsValidKey, string[]> = {
|
||||
"feed.subscriptions.audit_log": ["feed", "subscriptions", "auditLog"],
|
||||
"runtime.codex.hooks_enabled": ["runtime", "codex", "hooksEnabled"],
|
||||
"runtime.readiness_timeout_seconds": ["runtime", "readinessTimeoutSeconds"],
|
||||
"policies.claude_compaction.preparation_root": ["policies", "claudeCompaction", "preparationRoot"],
|
||||
"policies.claude_compaction.enabled": ["policies", "claudeCompaction", "enabled"],
|
||||
"policies.claude_compaction.threshold_percent": ["policies", "claudeCompaction", "thresholdPercent"],
|
||||
"policies.claude_compaction.pre_compact_instruction": ["policies", "claudeCompaction", "preCompactInstruction"],
|
||||
@@ -673,6 +676,7 @@ function getDefaultValue(key: SettingsValidKey, workspaceRoot: string): string |
|
||||
// the canonical restore skill; compact_instruction is intentionally
|
||||
// blank because Claude's native compact summary is less reliable
|
||||
// than the explicit pre-compact and post-compact user-channel flow.
|
||||
case "policies.claude_compaction.preparation_root": return "";
|
||||
case "policies.claude_compaction.enabled": return false;
|
||||
case "policies.claude_compaction.threshold_percent": return 80;
|
||||
case "policies.claude_compaction.pre_compact_instruction": return DEFAULT_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION;
|
||||
@@ -833,6 +837,11 @@ const KEY_CONSTRAINTS: Partial<Record<SettingsValidKey, (raw: string, coerced: s
|
||||
// "80.5" → 80) is not safe for a key the daemon's compaction trigger
|
||||
// reads at every poll tick; runtime validator rejects what the
|
||||
// contract forbids per banked feedback_static_gates_mirror_runtime_validators.
|
||||
"policies.claude_compaction.preparation_root": (_raw, value) => {
|
||||
if (typeof value !== "string" || (value !== "" && (!path.isAbsolute(value) || value.includes("\0")))) {
|
||||
throw new Error("Invalid policies.claude_compaction.preparation_root: use an absolute path or an empty string for the default");
|
||||
}
|
||||
},
|
||||
"policies.claude_compaction.threshold_percent": (raw, coerced) => {
|
||||
const trimmed = (raw ?? "").trim();
|
||||
if (!/^-?\d+$/.test(trimmed)) {
|
||||
@@ -950,6 +959,8 @@ function validateTypedFileValue(key: SettingsValidKey, value: string | number |
|
||||
* shape via direct config.json read (without depending on the daemon).
|
||||
*/
|
||||
export interface ClaudeCompactionPolicy {
|
||||
/** Empty or absent keeps workspace-first preparation; read for each new attempt. */
|
||||
preparationRoot?: string;
|
||||
enabled: boolean;
|
||||
thresholdPercent: number;
|
||||
preCompactInstruction: string;
|
||||
@@ -1108,6 +1119,7 @@ export class SettingsStore {
|
||||
const fc = this.readConfigFile();
|
||||
const wr = this.resolveWorkspaceRootRaw(fc);
|
||||
return {
|
||||
preparationRoot: this.resolveOne("policies.claude_compaction.preparation_root", fc, wr).value as string,
|
||||
enabled: this.resolveOne("policies.claude_compaction.enabled", fc, wr).value as boolean,
|
||||
thresholdPercent: this.resolveOne("policies.claude_compaction.threshold_percent", fc, wr).value as number,
|
||||
preCompactInstruction: this.resolveOne("policies.claude_compaction.pre_compact_instruction", fc, wr).value as string,
|
||||
|
||||
@@ -22,7 +22,7 @@ function fixture(manualPrepWaitMs:number|null=1000,guardDb?:Database.Database){
|
||||
let onSleep:(()=>Promise<void>)|undefined;
|
||||
const db=guardDb??({prepare:()=>({get:()=>undefined,all:()=>[]})} as any);
|
||||
const writes:string[]=[],keys:string[][]=[];
|
||||
const policy={enabled:true,thresholdPercent:80,preCompactInstruction:'Write a restore map.',compactInstruction:'',messageInline:'',messageFilePath:'',postRestoreAuditInstruction:''};
|
||||
const policy={preparationRoot:'',enabled:true,thresholdPercent:80,preCompactInstruction:'Write a restore map.',compactInstruction:'',messageInline:'',messageFilePath:'',postRestoreAuditInstruction:''};
|
||||
const tmux={probeSession:vi.fn(async()=>({state:'present'})),sendText:vi.fn(async(_:string,text:string)=>{writes.push(text);return{ok:true};}),sendKeys:vi.fn(async(_:string,k:string[])=>{keys.push(k);return{ok:true};})};
|
||||
const guard=new SeatDeliveryGuard(db,()=>({nodeId:'node-one',session:seat,pane:'%1',occupant:generation}));
|
||||
Object.assign(tmux,{deliveryGuard:guard});
|
||||
@@ -508,6 +508,182 @@ it('legacy fallback parent is created before asking the seat to edit its temp fi
|
||||
await f.e.maybeAutoCompact(input);expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
for (const mode of ['manual', 'automatic'] as const) it(`configured preparation root: ${mode} publishes outside the launch workspace`, async () => {
|
||||
const f=fixture(),cwd=join(f.home,'denied launch directory'),root=join(f.home,'allowed maps');
|
||||
mkdirSync(cwd);f.policy.preparationRoot=root;
|
||||
// A writable cwd is still selected away from: POSIX writability cannot prove Claude permission.
|
||||
mkdirSync(join(cwd,'.claude'));writeFileSync(join(cwd,'.claude','settings.json'),JSON.stringify({permissions:{deny:[`Edit(${cwd}/**)`]}}));
|
||||
if(mode==='manual'){
|
||||
f.onSleep(async()=>{stageMap(f);});
|
||||
expect((await routeFixture(f,cwd)()).status).toBe(200);
|
||||
}else{
|
||||
await f.e.maybeAutoCompact({...input,cwd});stageMap(f);
|
||||
await f.e.maybeAutoCompact({...input,cwd});
|
||||
}
|
||||
const a=f.e.getPreparationState(seat)!;
|
||||
expect(a.mapPath).toBe(join(root,seat,a.attemptId,'RESTORE-MAP.md'));
|
||||
expect(readFileSync(join(root,'.gitignore'),'utf8')).toBe('*\n');
|
||||
expect(existsSync(join(cwd,'.openrig'))).toBe(false);
|
||||
expect(readFileSync(a.mapPath,'utf8')).toContain(a.marker);
|
||||
expect(existsSync(a.mapPath+'.tmp')).toBe(false);
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
expect(f.writes[0]).toContain(JSON.stringify(a.mapPath+'.tmp'));
|
||||
f.policy.preparationRoot=join(f.home,'new maps');
|
||||
await f.e.maybeAutoCompact({...input,cwd,usedPercentage:20});
|
||||
await f.e.maybeAutoCompact({...input,cwd,usedPercentage:20});
|
||||
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${a.mapPath}; read it.`);
|
||||
expect(f.writes.at(-1)).toContain("read this preparation attempt's restore map and use the restore packet as a lookup");
|
||||
expect(f.writes.at(-1)).not.toContain('read your newest restore map');
|
||||
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
|
||||
});
|
||||
|
||||
it('configured preparation root: stays bound to the attempt while later seats use new settings',async()=>{
|
||||
const f=fixture(),root=join(f.home,'maps');f.policy.preparationRoot=root;
|
||||
await f.e.maybeAutoCompact(input);const first=f.e.getPreparationState(seat)!;
|
||||
f.policy.preparationRoot=join(f.home,'new maps');
|
||||
await f.e.maybeAutoCompact({...input,sessionName:'sibling@demo'});
|
||||
expect(f.e.getPreparationState('sibling@demo')!.mapPath).toContain(join(f.policy.preparationRoot,'sibling@demo'));
|
||||
expect(f.e.getPreparationState(seat)!.mapPath).toBe(first.mapPath);
|
||||
stageMap(f);await f.e.maybeAutoCompact(input);
|
||||
expect(readFileSync(first.mapPath,'utf8')).toContain(first.marker);
|
||||
});
|
||||
|
||||
for(const mode of ['manual','automatic'] as const)it(`configured preparation root: ${mode} keeps the bounded command and carries the original long path and instruction into restore`,async()=>{
|
||||
const f=fixture();
|
||||
const root=join(f.home,...Array.from({length:8},()=> 'allowed-map-directory-'.repeat(4)));
|
||||
const instruction='Keep the outstanding decision and its supporting source. '.repeat(30).trim();
|
||||
f.policy.preparationRoot=root;f.policy.compactInstruction=instruction;
|
||||
const finish=()=>{
|
||||
stageMap(f);
|
||||
f.policy.preparationRoot=join(f.home,'later maps');
|
||||
f.policy.compactInstruction='A later instruction belongs to a later attempt.';
|
||||
};
|
||||
if(mode==='manual'){
|
||||
f.onSleep(async()=>{finish();});
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
|
||||
}else{
|
||||
await f.e.maybeAutoCompact(input);finish();await f.e.maybeAutoCompact(input);
|
||||
}
|
||||
const attempt=f.e.getPreparationState(seat)!;
|
||||
expect(attempt.mapPath).toBe(join(root,seat,attempt.attemptId,'RESTORE-MAP.md'));
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
expect(compacts(f)[0]!.length).toBeLessThanOrEqual(600);
|
||||
expect(compacts(f)[0]).not.toContain(attempt.mapPath);
|
||||
expect(compacts(f)[0]).not.toContain(instruction);
|
||||
const unsampled={sessionName:seat,runtime:'claude-code'};
|
||||
await f.e.drainPendingPostCompactStage(unsampled);
|
||||
await f.e.drainPendingPostCompactStage(unsampled);
|
||||
const restore=f.writes.at(-1)!;
|
||||
expect(restore).toContain(`This attempt's restore map is ${attempt.mapPath}; read it.`);
|
||||
expect(restore).toContain(instruction);
|
||||
expect(restore).not.toContain(f.policy.preparationRoot);
|
||||
expect(restore).not.toContain(f.policy.compactInstruction);
|
||||
});
|
||||
|
||||
it('configured preparation root: skip-map still uses an optional map completed at this attempt path',async()=>{
|
||||
const f=fixture();f.policy.preparationRoot=join(f.home,'allowed maps');
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true})).triggered).toBe(true);
|
||||
const attempt=f.e.getPreparationState(seat)!;stageMap(f);
|
||||
f.policy.preparationRoot=join(f.home,'later maps');
|
||||
const unsampled={sessionName:seat,runtime:'claude-code'};
|
||||
await f.e.drainPendingPostCompactStage(unsampled);
|
||||
await f.e.drainPendingPostCompactStage(unsampled);
|
||||
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${attempt.mapPath}.tmp; read it.`);
|
||||
expect(f.writes.at(-1)).not.toContain('No restore map was required');
|
||||
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
|
||||
expect(existsSync(attempt.mapPath)).toBe(false);
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
|
||||
for(const mode of ['manual','automatic'] as const)it(`configured preparation root: ${mode} restore without a usage sample retains the published map until audit`,async()=>{
|
||||
const f=fixture();f.policy.preparationRoot=join(f.home,'original maps');
|
||||
if(mode==='manual'){
|
||||
f.onSleep(async()=>{stageMap(f);});
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(true);
|
||||
}else{
|
||||
await f.e.maybeAutoCompact(input);stageMap(f);await f.e.maybeAutoCompact(input);
|
||||
}
|
||||
const a=f.e.getPreparationState(seat)!;
|
||||
f.policy.preparationRoot=join(f.home,'later maps');
|
||||
const send=vi.spyOn(f.transport,'send');
|
||||
const unsampled={sessionName:seat,runtime:'claude-code'};
|
||||
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
|
||||
expect(f.e.getPreparationState(seat)?.mapPath).toBe(a.mapPath);
|
||||
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
|
||||
expect(f.writes.at(-1)).toContain(`This attempt's restore map is ${a.mapPath}; read it.`);
|
||||
expect(f.writes.at(-1)).not.toContain(f.policy.preparationRoot);
|
||||
expect(f.e.getPreparationState(seat)?.mapPath).toBe(a.mapPath);
|
||||
expect((await f.e.drainPendingPostCompactStage(unsampled)).triggered).toBe(true);
|
||||
expect(send.mock.calls).toHaveLength(3);
|
||||
for(const call of send.mock.calls)expect(call[2]).toMatchObject({readinessFromPaneOnly:true});
|
||||
expect(f.e.getPreparationState(seat)).toBeNull();
|
||||
expect(f.e.hasPendingPostCompactStage(seat)).toBe(false);
|
||||
expect(readFileSync(a.mapPath,'utf8')).toContain(a.marker);
|
||||
expect(compacts(f)).toHaveLength(1);
|
||||
});
|
||||
|
||||
for(const kind of ['file','ignore','symlink'] as const) it(`configured preparation root: ${kind} conflict stops without fallback or rewriting`,async()=>{
|
||||
const f=fixture(),root=join(f.home,'maps'),cwd=join(f.home,'cwd');mkdirSync(cwd);
|
||||
f.policy.preparationRoot=root;
|
||||
if(kind==='file')writeFileSync(root,'keep');
|
||||
else{
|
||||
mkdirSync(root);
|
||||
if(kind==='ignore')writeFileSync(join(root,'.gitignore'),'keep');
|
||||
else{writeFileSync(join(f.home,'outside'),'keep');symlinkSync(join(f.home,'outside'),join(root,'.gitignore'));}
|
||||
}
|
||||
expect(await f.e.maybeAutoCompact({...input,cwd})).toEqual({triggered:false,reason:'preparation_directory_unavailable'});
|
||||
expect(f.e.getPreparationState(seat)).toMatchObject({status:'stopped',reason:'preparation_directory_unavailable'});
|
||||
expect(f.e.getPreparationState(seat)!.mapPath).toContain(root);
|
||||
expect(f.writes).toHaveLength(0);
|
||||
expect(existsSync(join(cwd,'.openrig'))).toBe(false);
|
||||
expect(existsSync(join(f.home,'compaction'))).toBe(false);
|
||||
expect(readFileSync(kind==='file'?root:join(root,'.gitignore'),'utf8')).toBe('keep');
|
||||
});
|
||||
|
||||
it('configured preparation root: manual reports directory failure; explicit skip-map still works',async()=>{
|
||||
const f=fixture(),root=join(f.home,'maps');writeFileSync(root,'keep');f.policy.preparationRoot=root;
|
||||
expect(await f.e.triggerManualCompact(input,{operatorInitiated:true})).toMatchObject({triggered:false,reason:'preparation_directory_unavailable'});
|
||||
expect(f.writes).toHaveLength(0);
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true,skipMap:true})).triggered).toBe(true);
|
||||
expect(readFileSync(root,'utf8')).toBe('keep');
|
||||
await f.e.maybeAutoCompact({...input,usedPercentage:20});
|
||||
await f.e.maybeAutoCompact({...input,usedPercentage:20});
|
||||
expect(f.writes.at(-1)).toContain("No restore map was required for this compaction (--skip-map)");
|
||||
expect(f.writes.at(-1)).not.toContain("This attempt's restore map is");
|
||||
});
|
||||
|
||||
for(const mode of ['manual','automatic'] as const)for(const end of ['expiry','cancel','disable','replacement'] as const)it(`configured preparation root: ${mode} ${end} never publishes a late map`,async()=>{
|
||||
const f=fixture();f.policy.preparationRoot=join(f.home,'maps');
|
||||
const disarm=()=>{
|
||||
if(end==='expiry')f.advance(mode==='manual'?1001:AUTO_PREP_WAIT_MS_DEFAULT+1);
|
||||
if(end==='cancel')f.e.cancelPreparation(seat);
|
||||
if(end==='disable')f.policy.enabled=false;
|
||||
if(end==='replacement')f.generation('generation-two');
|
||||
f.e.reconcilePreparations();stageMap(f);
|
||||
};
|
||||
if(mode==='manual'){
|
||||
f.onSleep(async()=>{disarm();});
|
||||
expect((await f.e.triggerManualCompact(input,{operatorInitiated:true})).triggered).toBe(false);
|
||||
}else{
|
||||
await f.e.maybeAutoCompact(input);disarm();
|
||||
}
|
||||
f.policy.enabled=true;await f.e.maybeAutoCompact(input);
|
||||
const a=f.e.getPreparationState(seat)!;
|
||||
expect(a).toMatchObject({status:'stopped',reason:{expiry:'preparation_incomplete',cancel:'preparation_cancelled',disable:'disabled',replacement:'stale_generation'}[end]});
|
||||
expect(existsSync(a.mapPath)).toBe(false);expect(existsSync(a.mapPath+'.tmp')).toBe(true);
|
||||
expect(compacts(f)).toHaveLength(0);expect(f.writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.skipIf(process.getuid?.()===0)('configured preparation root: an unwritable descendant stops without fallback',async()=>{
|
||||
const f=fixture(),root=join(f.home,'maps'),parent=join(root,seat);mkdirSync(parent,{recursive:true});chmodSync(parent,0o500);
|
||||
f.policy.preparationRoot=root;
|
||||
try{
|
||||
expect(await f.e.maybeAutoCompact(input)).toEqual({triggered:false,reason:'preparation_directory_unavailable'});
|
||||
expect(f.writes).toHaveLength(0);expect(existsSync(join(f.home,'compaction'))).toBe(false);
|
||||
}finally{chmodSync(parent,0o700);}
|
||||
});
|
||||
|
||||
// Manual preparation owns this bounded attention wait; generic sends still refuse attention.
|
||||
it('manual attention clears within the same attempt with one preparation and one compact',async()=>{
|
||||
const f=fixture();let sleeps=0;let id='';
|
||||
|
||||
@@ -118,6 +118,8 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
|
||||
function buildApp(opts?: { wireEnforcer?: boolean; completeMap?: boolean; bearerToken?: string }): AppParts {
|
||||
const sentTexts: string[] = [];
|
||||
let clock = Date.now();
|
||||
const sleep = async (ms: number) => { clock += ms; };
|
||||
const transport = new SessionTransport({
|
||||
db,
|
||||
rigRepo,
|
||||
@@ -129,10 +131,14 @@ describe("compaction routes — POST /api/compaction/trigger", () => {
|
||||
if (opts?.completeMap !== false && marker && target) { const file = JSON.parse(target[1]!); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, "# Fixture map\n" + marker); }
|
||||
return { ok: true as const };
|
||||
}),
|
||||
sleep: async () => undefined,
|
||||
now: () => new Date(clock),
|
||||
sleep,
|
||||
waitForIdlePollMs: 1,
|
||||
});
|
||||
const enforcer = new ClaudeCompactionEnforcer(makeSettings(), transport, { openrigHome: stateDir, manualPrepWaitMs: 50, resolveOccupantGeneration: () => "fixture-generation" });
|
||||
const enforcer = new ClaudeCompactionEnforcer(makeSettings(), transport, {
|
||||
openrigHome: stateDir, manualPrepWaitMs: 1000, resolveOccupantGeneration: () => "fixture-generation",
|
||||
now: () => clock, sleep,
|
||||
});
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("sessionTransport" as never, transport);
|
||||
|
||||
@@ -356,8 +356,10 @@ describe("GET /api/provider/usage/hosts", () => {
|
||||
|
||||
describe("POST /api/provider/usage/hosts/refresh", () => {
|
||||
it("a refresh slower than the 5 s read class still succeeds, and its reading reaches the view", async () => {
|
||||
const a = await host("a", { codex: { delayMs: 6_000, secondaryUsed: 85 } });
|
||||
const hub = await host("hub", { registry: [a.entry()] });
|
||||
// Keep the scripted weekly window before its reset while the delay uses real timers.
|
||||
const now = () => "2026-10-14T00:00:00.000Z";
|
||||
const a = await host("a", { now, codex: { delayMs: 6_000, secondaryUsed: 85 } });
|
||||
const hub = await host("hub", { now, registry: [a.entry()] });
|
||||
const started = Date.now();
|
||||
const { status, body } = await refreshVia(hub, { provider: "codex" });
|
||||
expect(Date.now() - started).toBeGreaterThan(5_000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readlinkSync, readdirSync, realpathSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readlinkSync, readdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -74,7 +74,8 @@ it.each(["claude", "codex"])("D: nushell retains the previous literal %s command
|
||||
expect(command).not.toContain('"$PATH"'); expect(f.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["fresh", "resume", "fork"] as const)("C: %s uses the probe's separate Node interpreter while preserving user PATH", async kind => {
|
||||
const f = fixture(), nodeBin = path.dirname(process.execPath);
|
||||
const f = fixture(), nodeBin = path.join(f.root, "node interpreter");
|
||||
mkdirSync(nodeBin); symlinkSync(process.execPath, path.join(nodeBin, "node"));
|
||||
expect(["/usr/bin", "/bin", f.shared, f.runtime]).not.toContain(nodeBin);
|
||||
f.exe(path.join(f.runtime, "codex"), `#!/usr/bin/env node\nif(process.argv.includes('--help'))console.log('Usage: codex [OPTIONS]\\n --no-daemon');else console.log(JSON.stringify({started:true,node:process.execPath,path:process.env.PATH}));\n`);
|
||||
// There is no Node in the pane PATH. The capability query really executes this npm-style entry.
|
||||
|
||||
@@ -24,6 +24,7 @@ function clearEnv(): () => void {
|
||||
"OPENRIG_WORKSPACE_PROJECTS_ROOT", "OPENRIG_WORKSPACE_CATALOG_PATH",
|
||||
"OPENRIG_CONTEXT_ROOT", "OPENRIG_CONTEXT_PACKS_ROOT",
|
||||
"OPENRIG_LAUNCH_NON_INTERRUPTIVE",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
|
||||
"OPENRIG_BUNDLE_SERVICES_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
|
||||
@@ -116,13 +117,15 @@ describe("config routes (User Settings v0)", () => {
|
||||
// + 1 usage.pi_cost_facts (slice 02 Pi cost from persisted entries; default off).
|
||||
// + 1 usage.pi_openrouter_charge (slice 02 K4 OpenRouter charge beside the Pi estimate; default off).
|
||||
// + 1 transport.verify_input_box (input-box evidence for verified Claude Code and Codex sends; default off).
|
||||
expect(body.settings["policies.claude_compaction.preparation_root"]).toMatchObject({ value: "", source: "default" });
|
||||
// + 1 bundle.services_enabled (service archive packing, preview and target installs; default off).
|
||||
expect(body.settings["transport.local_user_guard"]).toMatchObject({ value: false, source: "default", defaultValue: false });
|
||||
expect(body.settings["bundle.services_enabled"]).toMatchObject({ value: false, source: "default", defaultValue: false });
|
||||
// (provider.subscription_moves was removed with the subscription move record, which nothing acted on.)
|
||||
// + 1 terminal.history_limit (rows of tmux scrollback in the block rig setup writes; default 10000).
|
||||
// + 1 skills.router_line.enabled (the seat-skills bootstrap line; default off).
|
||||
expect(Object.keys(body.settings).length).toBe(84);
|
||||
// + 1 instance-wide Claude compaction preparation root (#1445).
|
||||
expect(Object.keys(body.settings).length).toBe(85);
|
||||
expect(body.settings["terminal.history_limit"]).toMatchObject({ value: 10000, source: "default", defaultValue: 10000 });
|
||||
expect(body.settings).not.toHaveProperty("provider.subscription_moves");
|
||||
expect(body.settings["launch.non_interruptive"]).toMatchObject({ value: false, source: "default" });
|
||||
|
||||
@@ -48,6 +48,7 @@ function clearEnv(): () => void {
|
||||
"OPENRIG_RUNTIME_CODEX_HOOKS_ENABLED",
|
||||
"OPENRIG_RUNTIME_READINESS_TIMEOUT_SECONDS",
|
||||
// Slice 27 — Claude auto-compaction policy env-map.
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_ENABLED",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_THRESHOLD_PERCENT",
|
||||
"OPENRIG_POLICIES_CLAUDE_COMPACTION_PRE_COMPACT_INSTRUCTION",
|
||||
@@ -136,6 +137,7 @@ describe("SettingsStore (User Settings v0)", () => {
|
||||
"runtime.codex.hooks_enabled",
|
||||
"runtime.readiness_timeout_seconds",
|
||||
// Slice 27 — Claude auto-compaction policy. SC-29 EXCEPTION #10.
|
||||
"policies.claude_compaction.preparation_root",
|
||||
"policies.claude_compaction.enabled",
|
||||
"policies.claude_compaction.threshold_percent",
|
||||
"policies.claude_compaction.pre_compact_instruction",
|
||||
@@ -887,3 +889,39 @@ describe("parseNamedPairs (daemon copy)", () => {
|
||||
expect(parseNamedPairs(" ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("compaction preparation root", () => {
|
||||
let tmpDir: string, configPath: string;
|
||||
let restoreEnv: () => void;
|
||||
beforeEach(() => {
|
||||
restoreEnv = clearEnv();
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "preparation-root-settings-"));
|
||||
configPath = join(tmpDir, "config.json");
|
||||
});
|
||||
afterEach(() => { restoreEnv(); rmSync(tmpDir, { recursive: true, force: true }); });
|
||||
const key = "policies.claude_compaction.preparation_root";
|
||||
it("resolves file/env overrides consistently and resets to workspace-first preparation", () => {
|
||||
const store = new SettingsStore(configPath);
|
||||
const root = join(tmpDir, "maps with spaces");
|
||||
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
|
||||
store.set(key, root);
|
||||
expect(store.resolveOne(key)).toMatchObject({ value: root, source: "file" });
|
||||
expect(new SettingsStore(configPath).resolveClaudeCompactionPolicy().preparationRoot).toBe(root);
|
||||
process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT = join(root, "override");
|
||||
try {
|
||||
expect(store.resolveOne(key)).toMatchObject({ value: join(root, "override"), source: "env" });
|
||||
} finally { delete process.env.OPENRIG_POLICIES_CLAUDE_COMPACTION_PREPARATION_ROOT; }
|
||||
store.reset(key);
|
||||
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
|
||||
store.set(key, "");
|
||||
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe("");
|
||||
});
|
||||
it.each(["relative/maps", "~/maps", " /maps", "/maps\0invalid"])("rejects invalid root %j without replacing valid settings", (value) => {
|
||||
const store = new SettingsStore(configPath);
|
||||
const root = join(tmpDir, "maps");
|
||||
store.set(key, root);
|
||||
expect(() => store.set(key, value)).toThrow(/absolute path/);
|
||||
expect(store.resolveClaudeCompactionPolicy().preparationRoot).toBe(root);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user