Compare commits

...
Author SHA1 Message Date
dev60-review b5efc980b0 fix(compaction): the post-restore audit message matches the restore skill
The managed audit message told a restored Claude seat to audit "before
doing any other work" and to report the read-depth table "before
continuing". The claude-compaction-restore skill says the required
reads come before acting on the restored work, a hold the seat verified
as live still governs the work it covers, and the table and reconnect
note are written as the seat resumes without holding unaffected work.

The message now says the same. Only its first and last sentences change;
when it is sent and the operator instruction join are unchanged. The
enforcer test pins the new sentences and the old order's absence.

Refs #3111
2026-11-13 04:45:56 +00:00
Mike Schwarz b1460a11d9 docs(skills): after a compaction, a hold covers only its own work (#3111)
A Claude seat restoring after a compaction reads a hold as governing only the work it covers. Three lines in the claude-compaction-restore skill change. A hold you verified as live, a release order or an operator's own restore map overrides the default order for the work it covers, and the seat does the unaffected work. The reconnect note waits on a live hold that covers its next step. The read-depth table and the reconnect note are written as the seat resumes. "Rebuild the connections before you act on anything" is unchanged, so reading still comes first, and a live hold still stops the work it covers. The managed post-restore audit message still asks for the table before other work; when it arrives, it is the live instruction. The skill's digest is regenerated.
2026-11-12 19:41:07 -08:00
Mike Schwarz d4855cbbec docs: scope two promises to what they cover (#3106)
Two sentences now say exactly what they cover. The startup guide's managed-block line reads "tidying the copies never removes a text you wrote": only a copy that repeats a kept one, or OpenRig's own copy beside one you edited, is taken out, while a launch can still replace a block OpenRig has no record of writing when it differs from its source. rig down's help says that for a community harness only its pane stops; its own processes may keep running, and rig down names it. Text only, with no behaviour change.
2026-11-12 19:39:11 -08:00
Mike Schwarz b5df49407c perf(daemon): name the queue's synchronous reads in the slow-operation log (#3100)
When the daemon's event loop stalls, the slow-operation log can now name three more of the queue's synchronous reads as the cause: a whole handoff (queue.handoff), the stuck sweep's linked-successor read (queue.linked_successors.build), and the reply read behind each row's decision revision (queue.decision_revision.replies). The first two record a begin and an end. The third records only a call at or above the recorder's existing slow threshold, through a new runSyncSiteWhenSlow, so the bounded log isn't crowded; such a call is named once it ends, not while it runs.

This changes no behaviour: a failing record never replaces the wrapped call's own result or error. It adds evidence for the next diagnosis, not a cause; event-loop gaps that match no logged operation stay unattributed.
2026-11-12 19:01:39 -08:00
11 changed files with 138 additions and 13 deletions

No files matched your search

+1 -1
View File
@@ -423,7 +423,7 @@ Each startup file and action specifies when it applies:
- a launch replaces it, as launches always did, and names it (`guidance_block_refreshed_unrecorded`);
- an exact resume or a handover leaves it, and names it as possibly stale (`guidance_block_may_be_stale`).
**A file keeps one copy of each distinct block text, and nothing you wrote is removed.**
**A file keeps one copy of each distinct block text, and tidying the copies never removes a text you wrote.**
- **Touched markers:** a marker an editor or formatter rewrapped, with spaces or line breaks inside the comment, still reads as its block, and the next write puts it back in OpenRig's form.
- **Collected copies:** a copy that repeats an earlier one exactly is removed. Beside a copy you edited, OpenRig's own unedited copies are removed too, and every different edited copy stays (`guidance_block_copies_removed`).
- **A marker that lost its pair:** OpenRig can't tell where that block starts or ends, so it adds no copy beside it and names it (`guidance_block_markers_damaged`). Put back the missing marker or remove the partial block, and OpenRig's next write treats that block like any other. `rig package install` leaves such a file as it is, names it in its result (`notes`), and installs the rest. A marker without its pair beside a complete block is named too (`guidance_block_marker_unpaired`), and the block is written.
+2 -1
View File
@@ -143,7 +143,8 @@ export function downCommand(depsOverride?: DownDeps): Command {
const cmd = new Command("down").description("Tear down a rig")
.addHelpText("after", `
rig down stops every agent OpenRig started in the rig: their sessions end, and any work in progress stops
with them. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
with them. For a community harness only its pane stops: its own processes may keep running, and rig down
names it. A session OpenRig didn't start (adopted, bound or discovered) is left running, and rig down names it.
Before running it, check who is working (rig ps --nodes --rig <rig>) and that the person asked for it;
don't stop a team you didn't start unless asked. OpenRig snapshots the rig first (best-effort), and
rig up <rig> --existing brings it back, resuming each seat's conversation where its harness allows. With
@@ -163,8 +163,8 @@ is not a restore request.
1. **Check for a hold first.** Read the restore request, the per-seat instruction file
(`<OPENRIG_HOME>/compaction/post-compact-extra/<session>.md`, named after your full session, such as
`dev-impl@my-rig` with `.md` added) when it exists, the newest row or message from whoever routes your work, and any hold
from the authority above them. A hold, a release order or an operator's own restore map overrides the default
order below. Before any write, also run `rig whoami --json` and `rig queue whoami`.
from the authority above them. A hold you verified as live, a release order or an operator's own restore map overrides
the default order below for the work it covers; do the unaffected work. Before any write, also run `rig whoami --json` and `rig queue whoami`.
2. **Name your class and state its read budget** before reading (see "Two restore classes"), with a
checkpoint at each step below. The budget exists so that the restore leaves room for the work it was
restored to do. Step 5 takes a high-context seat to its Tier 2 line; the extra sources under "Two restore classes" apply only
@@ -218,7 +218,7 @@ The audit message asks for a read-depth table and tells you not to conserve toke
- where you are in time: what earlier windows established, what the last window did, what is true now;
- the connections you have rebuilt, in a few lines;
- the connections you could not rebuild, and where you would look;
- the next authorized step and who authorizes it. If a hold stands, the next step is waiting.
- the next authorized step and who authorizes it. If a hold you verified as live covers it, wait on that; do the unaffected work.
The next restore map links this note, which keeps the chain unbroken.
@@ -238,7 +238,7 @@ unaffected work.
- Do not launch a fresh session in place of restoring.
- An honest `PARTIAL` with its reason is a correct outcome. Claiming coverage you did not reach is the
failure.
- Do not resume task work until the read-depth table and the reconnect note exist.
- Write the read-depth table and the reconnect note as you resume; don't hold unaffected work for them.
## Failure modes
@@ -398,7 +398,7 @@ export function buildPostCompactRestorePrompt(input: {
function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string | null): string {
const pieces = [
"Now audit your compaction restore before doing any other work.",
"Now audit your compaction restore: read the required items below before acting on the restored work, and keep any hold you verified as live.",
];
const instruction = postRestoreAuditInstruction?.trim();
if (instruction) {
@@ -409,7 +409,7 @@ function buildPostCompactCompliancePrompt(postRestoreAuditInstruction?: string |
"For each item, mark read depth as FULL, PARTIAL, or NOT_READ.",
"Include the refocusing skill and the topology and work trace sources. Account for the current traces actually delivered during restore; do not rerun Python to duplicate them. If no current trace arrived, report that delivery gap. Read required notes and full sources with the native file-read tool; pointers, compact summaries and truncated extracts do not count as full source reads.",
"Required items are your restore map's ranked entries above your restore class's tier line (with no map: the instruction files and the packet's restore-instructions.md); the other restore packet files and the session JSONL stay lookup-only.",
"Read every required item that is not FULL in full now, without skimming, then report the final read-depth table before continuing.",
"Read every required item that is not FULL in full now, without skimming. Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.",
);
return pieces.join(" ");
}
+11 -4
View File
@@ -105,6 +105,7 @@ import { armQueueWait, backOffQueueWait, refreshQueueWaits, evaluateQueueWait, r
import { parseHumanQuestions, unansweredQuestions, describeTypedReplyPlacement, formatHumanAnswers, humanAnswerText, type HumanQuestion, type HumanAnswers, type TypedHumanReply, type RecordHumanAnswerResult } from "./human-questions.js";
import { slackMicros } from "./gateway/slack/state-store.js";
import { askBackRoute, RUNNER_CLOSE_WAITS, terminalAnswerMeaning } from "./receiver-context.js";
import { runSyncSite, runSyncSiteWhenSlow } from "./sync-site-wrap.js";
/** A direct ask's answer row ID: from the ask, or from the ask and the park episode its answer belongs to. */
function humanAnswerId(askId: string, episodeKey?: string): string {
@@ -821,14 +822,14 @@ export function createLinkedSuccessorLookup(db: Database.Database, sourceQitemId
const startedAt = performance.now();
const map = new Map<string, Array<{ qitemId: string; destination: string | null }>>();
const ids = JSON.stringify([...sources]);
const rows = db.prepare(
const rows = runSyncSite("queue.linked_successors.build", () => db.prepare(
`SELECT s.qitem_id, s.destination_session, s.handed_off_from AS source FROM queue_items s
WHERE s.handed_off_from IN (SELECT value FROM json_each(?))
UNION
SELECT s.qitem_id, s.destination_session, j.value AS source FROM queue_items s, json_each(s.chain_of_record) j
WHERE s.chain_of_record IS NOT NULL AND json_valid(s.chain_of_record)
AND j.value IN (SELECT value FROM json_each(?))`,
).all(ids, ids) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
).all(ids, ids)) as Array<{ qitem_id: string; destination_session: string | null; source: string }>;
for (const r of rows) {
const list = map.get(r.source);
const entry = { qitemId: r.qitem_id, destination: r.destination_session };
@@ -2634,8 +2635,13 @@ export class QueueRepository {
}
/** {@link handoff}'s whole act, synchronously: its transaction commits before this returns, and the wake is delivered
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. */
* after commit without being awaited, so a synchronous caller (an answer's close path) can hand a row on. Named in
* the slow-operation log, so a stall during a handoff is attributed to it. */
private handoffNow(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
return runSyncSite("queue.handoff", () => this.handoffWithin(input));
}
private handoffWithin(input: QueueHandoffInput): { closed: QueueItem; created: QueueItem } {
const source = this.getById(input.qitemId);
if (!source) {
throw new QueueRepositoryError(
@@ -4563,8 +4569,9 @@ export class QueueRepository {
typeof opts?.excludeOperationReply === "string" && opts.excludeOperationReply.length > 0
? mcOperationTag(opts.excludeOperationReply)
: null;
// Named only when slow: it runs on every row read and on each row of the attention read.
const replies = this.hasReplyToColumn
? (excludeOp
? runSyncSiteWhenSlow("queue.decision_revision.replies", () => excludeOp
? (this.db
.prepare(
"SELECT qitem_id FROM queue_items WHERE reply_to = ? AND NOT (tags IS NOT NULL AND json_valid(tags) AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)) ORDER BY ts_created, rowid",
@@ -48,6 +48,8 @@ export interface SlowOperationInstrumentation {
classify?: (value: T) => "ok" | "failed",
): Promise<T>;
recordRequest?(site: string, durationMs: number): void;
/** A synchronous site's duration, recorded only at or above the slow threshold (runSyncSiteWhenSlow). */
recordSlowSync?(site: string, durationMs: number, outcome: "ok" | "failed"): void;
/** Bounded nonblocking diagnostic append; false means observation was dropped. */
recordDiagnostic?(record: Record<string, unknown>): boolean;
snapshot?(): SlowOperationSnapshot;
@@ -262,6 +264,11 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
this.recordMeasurement(`request:${site}`, durationMs);
}
recordSlowSync(site: string, durationMs: number, outcome: "ok" | "failed" = "ok"): void {
if (!Number.isFinite(durationMs) || durationMs < this.slowThresholdMs) return;
this.recordMeasurement(site, durationMs, outcome);
}
recordDiagnostic(record: Record<string, unknown>): boolean {
this.diagnostic.offered++;
const coverage = { ...this.diagnostic, pending: this.diagnosticIds.size,
@@ -10,6 +10,26 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
return recorder?.runSync ? recorder.runSync(site, fn) : fn();
}
/**
* runSyncSite for a site on a hot path (per request, per row), where a begin and an end record per call would crowd
* the bounded log: it records only a call that took at least the recorder's slow threshold, and writes no begin
* record, so a call that wedges the loop is named once it ends rather than while it runs.
*/
export function runSyncSiteWhenSlow<T>(site: string, fn: () => T): T {
const record = recorder?.recordSlowSync?.bind(recorder);
if (!record) return fn();
const startedAt = performance.now();
let outcome: "ok" | "failed" = "failed";
try {
const value = fn();
outcome = "ok";
return value;
} finally {
// Measurement only: a failing record never replaces fn's own result or error.
try { record(site, performance.now() - startedAt, outcome); } catch { /* the recorder reports its own health */ }
}
}
/**
* Async twin of runSyncSite: instruments a NON-blocking site through the
* recorder's async `runStage` seam (falls back to a bare call when no recorder
@@ -843,6 +843,9 @@ describe("ClaudeCompactionEnforcer", () => {
expect(send.mock.calls[4]![1]).toContain("Required items are your restore map's ranked entries above your restore class's tier line");
expect(send.mock.calls[4]![1]).toContain("the session JSONL stay lookup-only");
expect(send.mock.calls[4]![1]).toContain("Read every required item that is not FULL in full now");
expect(send.mock.calls[4]![1]).toContain("before acting on the restored work, and keep any hold you verified as live");
expect(send.mock.calls[4]![1]).toContain("Report the final read-depth table and the reconnect note as you resume; don't hold unaffected work for them.");
expect(send.mock.calls[4]![1]).not.toContain("before doing any other work");
expect(send.mock.calls[4]![1]).not.toContain("Do not optimize for token conservation");
expect(send.mock.calls[3]![1]).toContain("read your newest restore map and use the restore packet as a lookup");
expect(send.mock.calls[3]![1]).not.toContain("read the restore packet files");
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it } 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 { EventBus } from "../src/domain/event-bus.js";
import { QueueRepository, createLinkedSuccessorLookup } from "../src/domain/queue-repository.js";
import { configureSyncSiteRecorder, runSyncSiteWhenSlow } from "../src/domain/sync-site-wrap.js";
// The queue's synchronous reads named in the slow-operation log, so an event-loop stall during one is attributed to it.
// A fake recorder takes every call; the real recorder's slow threshold is pinned in slow-op-recorder.test.ts.
describe("queue reads named in the slow-operation log", () => {
let db: Database.Database;
let repo: QueueRepository;
const sync: string[] = [];
const slow: Array<{ site: string; outcome: string }> = [];
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db));
configureSyncSiteRecorder({
runSync<T>(site: string, fn: () => T): T { sync.push(site); return fn(); },
recordSlowSync(site: string, _durationMs: number, outcome: "ok" | "failed"): void { slow.push({ site, outcome }); },
});
});
afterEach(() => { configureSyncSiteRecorder(undefined); sync.length = 0; slow.length = 0; db.close(); });
it("names a handoff", async () => {
const source = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
sync.length = 0;
const { created } = await repo.handoff({ qitemId: source.qitemId, fromSession: "relay@r", toSession: "worker@r", nudge: false });
expect(created.destinationSession).toBe("worker@r");
expect(sync).toContain("queue.handoff");
});
it("names decisionRevision's reply read through the slow-only site, once per revision", async () => {
const ask = await repo.create({ sourceSession: "planner@r", destinationSession: "relay@r", body: "work", nudge: false });
slow.length = 0;
const revision = repo.decisionRevision(repo.getById(ask.qitemId)!);
expect(typeof revision).toBe("string");
expect(slow).toEqual([{ site: "queue.decision_revision.replies", outcome: "ok" }]);
});
it("names the stuck sweep's linked-successor build when it is built", () => {
const lookup = createLinkedSuccessorLookup(db, ["qitem-source"]);
expect(sync).toEqual([]);
expect(lookup.has("qitem-source", "worker@r")).toBe(false);
expect(lookup.has("qitem-source", "other@r")).toBe(false);
expect(sync).toEqual(["queue.linked_successors.build"]);
});
it("keeps the wrapped call's result and error, and records each outcome", () => {
expect(runSyncSiteWhenSlow("test.site", () => 7)).toBe(7);
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
expect(slow).toEqual([{ site: "test.site", outcome: "ok" }, { site: "test.site", outcome: "failed" }]);
});
it("never lets a failing record replace the wrapped call's result", () => {
configureSyncSiteRecorder({ recordSlowSync(): void { throw new Error("recorder gone"); } });
expect(runSyncSiteWhenSlow("test.site", () => "value")).toBe("value");
expect(() => runSyncSiteWhenSlow("test.site", () => { throw new Error("read failed"); })).toThrow("read failed");
});
it("runs the call bare when no recorder is configured", () => {
configureSyncSiteRecorder(undefined);
expect(runSyncSiteWhenSlow("test.site", () => "bare")).toBe("bare");
expect(slow).toEqual([]);
});
});
@@ -21,6 +21,7 @@ const expectedSites = new Map<string, string[]>([
["domain/codex-thread-id.ts", ["codex_thread_id.resolve_home"]],
["domain/review/gather.ts", ["review.gather.git"]],
["domain/tmux-option-defaults.ts", ["tmux_options.command_v"]],
["domain/queue-repository.ts", ["queue.decision_revision.replies", "queue.linked_successors.build", "queue.handoff"]],
]);
const pluginVendorUrl = "https://github.com/mvschwarz/openrig-plugins/releases/latest/download/openrig-core.tar.gz";
@@ -290,6 +291,23 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
await recorder.close();
});
it("records a slow synchronous site only at or above the slow threshold, with its outcome", async () => {
const mod = await loadRecorderModule();
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
if (!mod) return;
const dir = tempDir();
const logPath = path.join(dir, "slow-operations.jsonl");
const recorder = new mod.SlowOpRecorder({ logPath, slowThresholdMs: 250 });
recorder.recordSlowSync("queue.decision_revision.replies", 249, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 250, "ok");
recorder.recordSlowSync("queue.decision_revision.replies", 400, "failed");
await recorder.flush();
const records = (await readRecords(logPath)).filter((record) => record.site === "queue.decision_revision.replies");
expect(records.map((record) => [record.phase, record.durationMs, record.outcome])).toEqual([["end", 250, "ok"], ["end", 400, "failed"]]);
await recorder.close();
});
// The request-timing MIDDLEWARE CONTRACT, hermetic + deterministic. The prior "real server" form
// ran the full daemon (event-loop monitor + a globalThis.fetch monkey-patch), which is structurally
// ambient-fragile under the concurrent-workspace gate: it intermittently returned 200 for an unmatched
+1 -1
View File
@@ -69,7 +69,7 @@
"agent-operated-software/SKILL.md": "ec910bf5f631d7f20872786a565a0437a446fa2d5fd8b52b685979978243a6a8",
"agent-operated-workflows/SKILL.md": "24e54cbfa979999f523516112670618b2926ae5d42c7aa0846c94db214857ea5",
"applying-a-permission-policy/SKILL.md": "f7bba8d50b10fc8715f40a57bf82677ca9388c6718c8a162833d9c6daf2afdca",
"claude-compaction-restore/SKILL.md": "79a707974824e91d5888c7bc10b152989ff891491f1adecf3e93991e2af55f81",
"claude-compaction-restore/SKILL.md": "cd8369a62647ad389a9b0cc91c6f784a0d93e2d2b874bf9570d47c2134647d1c",
"claude-compaction-restore/reference/SKILL-v1.md": "6af2a889c9c38227fb186897c6e29185f81b215822070b469a6ceeb47734e133",
"claude-compaction-restore/scripts/precompact-hook.mjs": "712e97c8beb02eb6c2815884a960778a225d8f6238dfe04a9b0547c34eaa131d",
"claude-compaction-restore/scripts/restore-from-jsonl.mjs": "5584cff4ab36cd843e14c81bd201e4a2f8e498e995abacc4f81ec6c1fc556b10",