Compare commits

...
Author SHA1 Message Date
review50-r3andClaude Opus 5.5 fbd4d7aa35 fix(daemon): execution view's candidate scan yields between rowid windows
The cooperative candidate read yielded only between returned rows. With a
mission filter that matches few rows, one native step scanned queue history
until the next match, so a sparse match could hold the event loop for the
whole walk. The read now runs the same statement over rowid windows of 256
inside one read transaction and yields between windows. The filter stays in
SQL, so the rows and their order are unchanged. Inside a transaction, or for a
memory or non-WAL database, the read is still one synchronous statement on the
main handle.

A projection that takes 250 ms or more now offers one record to the
slow-operation log: projection and candidate-read elapsed times, the candidate
read's longest synchronous stretch, and row and window counts. No row content.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 02:43:56 +00:00
Mike Schwarz 653d77e80d fix(daemon): node inventory builds take turns so reads behind them stay responsive (#3101)
While something asks for a rig's nodes many times at once (the TUI, rig ps --nodes across rigs, several agents), the daemon now keeps answering everything else. GET /api/rigs/:rigId/nodes built its answer with synchronous SQLite reads, so requests that arrived together ran their whole builds back to back; a health check behind a burst of 24 waited up to 584 ms on a loaded machine. Now the route's three synchronous steps (the inventory build, the default-path activity attach, and terminal activity and work) take turns across requests, one at a time, each after a turn of the event loop.

A read queued behind a burst waits for the one step that's running. A single step isn't time-bounded, so one very large rig's inventory build is still one step. Each response is built from the same reads in the same order, but writes can now land between steps, so a response is no longer a single snapshot. In 24-request bursts the median nodes response came about twice as late (about 43 ms against 21 ms). The D2 responsiveness test now takes 20 health probes during the burst instead of 1, with the same 250 ms budget for the worst probe.
2026-11-12 18:09:32 -08:00
Mike Schwarz 59845be8f7 docs(network): say what a failed replace can leave in the key folder (#3084)
The network connector reference said a rig network enroll --replace that fails after the account accepted the code leaves nothing changed. That was broader than the code. The enrollment's record does roll back, and the key is put back too, but the key swap's file moves happen outside that rollback. If putting the key back also fails, the old key is left as key.json.prev in the installation's folder under network-secrets in the OpenRig home, and the reference now says to move it back to key.json before going on. A leftover key.json.next is a staged new key that the next replace removes. Text only.
2026-11-12 17:26:10 -08:00
Luis Araya 55cc990d28 fix(cli): explain context-pressure fallback after config writes (#2567)
Setting or resetting health.context_pressure.warning_percent or critical_percent saved the file value, but when an environment override made the resolved pair inverted (warning at or above critical), OpenRig silently fell back to its 95/99 defaults. Now the command reports the saved or cleared file value, the effective value and its default source, the rejected inverted pair with the environment variables that formed it, and that this shell falls back to 95/99. Sets and resets without a conflict print as before, a whole-file reset still recovers a malformed config, and nothing new is refused. General reporting of masked environment overrides stays with the existing config get --with-source path.

Thanks to Totopo27.
2026-11-12 17:17:39 -08:00
Mike Schwarz 9bbb94a0d2 fix(mcp): show a connected assistant's person plain tool titles and openings (#3092)
An assistant connected to a team through a grant service shows its person each tool's title and the first sentence of its description. The work tools had no title, so a client showed a humanized raw name such as "Rig queue list". Now eight tools carry a plain title, set in both title and annotations.title, and each description opens with a plain sentence, such as "Send a request: Sends a request to another assistant on your team."

Only the first sentence changes. Every rule after it, the read and change hints, and the parameter names stay as the model reads them today. Local callers (stdio, a token file or an owner grant) keep the tools' own text, because three of the plain sentences aren't true for them; rig_whoami's plain sentence is true for every caller, so every caller gets it. Claude's tool approval prompt still shows the raw tool name when a title is set, so that line doesn't change yet.
2026-11-12 17:14:20 -08:00
Mike Schwarz 53165ddf45 fix(mcp): a seat's plain name reaches its actual session, and an unknown name says so (#3088)
A person can tell their connected assistant to hand a task to a seat by the name their team shows, and the task now reaches that seat and is stored under its actual session. Before, a name reached a seat only through its pod-member form, so a seat attached under its own session name never saw the task.

For a grant-service connection, a name alone matches a seat's session name, then its pod-member form, then that session name's slug, counting only seats the grant can address. The configured receiver resolves the same way. Two seats sharing a slug with no exact match return destination_ambiguous with the candidates, a name no reachable seat has returns destination_not_found, and a team that can't be read returns destination_unchecked; nothing is written in each case. A name the grant can't address stays out_of_scope and is refused before the team is read. Launched seats and connections to several rigs behave as before.
2026-11-12 16:55:33 -08:00
Mike Schwarz 63a96d2508 fix(queue): index bounded listings over retained history (#3090)
A large retained queue could make a bounded queue list scan and sort its history on the daemon thread, delaying sends and wakes. Migration 166 adds five indexes that match the existing creation and update ordering, with partial indexes for live and archived owner notifications, and recovery lookups share one ordering so they keep the small partial index. Filters, limits and result order are unchanged.

On a copy with 28,976 queue rows, a full list of all rows went from 235.8 ms to 63.5 ms and a compact list from 105.0 ms to 3.8 ms (domain timings, not HTTP latency). Active-only listings show no gain, and one rig with active states was slower (99.8 ms to 133.3 ms). The indexes took about 2.9 MB and 3.47 s to build at first start on that copy; write-time overhead is unmeasured.
2026-11-12 16:50:47 -08:00
Roberto Campos a84980b92c fix(daemon): warn on validate that harness pins are not checked against seats (#3087) (#3102)
rig workflow validate on a spec with harness: pins used to return ok: true with no issues, so it read as if the pins could be satisfied, and the run then failed with harness_pin_unsatisfied. Now each pinned step gets a harness_pin_unchecked warning, and ok stays true. The warning names the step, the harness and the role, says validate didn't check the pin against any seats (no rig is bound, or the spec's target.rig), and says the run will fail with harness_pin_unsatisfied if no preferred target of the role runs that harness.
2026-11-12 16:31:30 -08:00
Mike Schwarz a279981029 docs: relay binding with its handle, and what skill restore keeps (#3096)
Three lines of the capability map now match what OpenRig does. The relay binding example carries its handle (…:primary:handle=<alias>), and a binding without a handle is outbound-only, because replies resolve by sender handle. on_restore: follow_source refreshes a skill only while the seat's copy is still what OpenRig wrote: an edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy. keep_saved's limit is per file: a missing file comes back only from a source that still has the bytes OpenRig wrote, so one changed helper file can stay missing while the skill's SKILL.md is unchanged. Text only.
2026-11-12 16:19:04 -08:00
25 changed files with 1091 additions and 164 deletions

No files matched your search

+7
View File
@@ -177,6 +177,13 @@ and nothing else is offered:
`rig_queue_create` that leaves out `destination` goes to that receiver. The operator sets it; a caller can't. It must be
exactly one existing local node within that request's grant, or nothing is written. An explicit `destination` works as
before.
- **A seat by its name:** a `destination` with no `@` names a seat as its team shows it: its session's own name, then its
pod-member form, then that name's slug, in the connection's one rig or the configured receiver's. Only seats the grant
can address count, and the first of these that any of them matches decides, so another seat's slug never makes an exact
name ambiguous and a seat the grant can't address never shadows one it can. The item is stored under that
seat's actual session. A slug two seats match returns `destination_ambiguous` with the candidates, and a name that no
seat the grant can reach has returns `destination_not_found`. A name the grant can't address as written (its own seat,
or another assistant's without `team`/`list`) stays `out_of_scope`, whether or not that seat exists.
- **Two assistants together:** a grant with `team`/`list` sees its rigs' seats with `rig_team`, and may address and hear from
another connected assistant's seat. A grant without it never reaches a seat in the service's caller form
(`cus-<id>-<connection>@<rig>`), so an existing connection keeps the reach it had.
+7 -3
View File
@@ -60,9 +60,13 @@ waits for any other change to this enrollment to finish, within the same 30 seco
waiting says so and doesn't send the code. The account makes a new installation, with a new key, for the new code. Here,
that key takes the old one's place and the bindings made through the old installation end, in one step:
- If that step fails after the account accepted the code, nothing here changes, and the error says so. The old
enrollment keeps working only while the account keeps the old installation, and the account now has a new
installation whose key nothing here holds: remove it on the account page, then enroll again with a new code.
- If that step fails after the account accepted the code, the enrollment's record here doesn't change, and the error
says so. The key is put back too, but moving key files happens outside that record's rollback. If putting it back also
fails (on a full or read-only disk, for example), the old key is left as `key.json.prev` beside `key.json` in the
installation's folder under `network-secrets/` in the OpenRig home: move it back to `key.json` before you go on. A
`key.json.next` there is a staged new key, and the next replace removes it. The old enrollment keeps working only
while the account keeps the old installation, and the account now has a new installation whose key nothing here
holds: remove it on the account page, then enroll again with a new code.
- Once the step is done, `enroll` names the bindings it ended. The old installation may still list their names, and the
new key can't drop them, so remove them on the account page. `rig network status` marks each of them as possibly still
listed under the old installation, as it does a binding that ended earlier without the account confirming the drop:
+53 -9
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { ConfigStore, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { ConfigStore, ENV_MAP, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { initWorkspaceCommand } from "./config-init-workspace.js";
function formatRow(key: string, value: unknown): string {
@@ -10,6 +10,40 @@ function summarizeSettings(store: ConfigStore): Record<ValidKey, ResolvedSetting
return store.resolveAllWithSource();
}
interface ContextPressureFallbackInfo {
effectiveValue: number;
explanation: string;
}
/**
* Returns fallback explanation and effective default value if setting/resetting a context-pressure
* key caused an inverted threshold pair to be rejected and fall back to 95/99 defaults in this shell.
* Returns null if no pair rejection occurred.
*/
function getContextPressureFallbackInfo(store: ConfigStore, key: string): ContextPressureFallbackInfo | null {
if (key !== "health.context_pressure.warning_percent" && key !== "health.context_pressure.critical_percent") {
return null;
}
const state = store.resolveContextPressurePolicyState();
if (!state.rejectedPair) {
return null;
}
const warnKey = "health.context_pressure.warning_percent" as const;
const critKey = "health.context_pressure.critical_percent" as const;
const envSources: string[] = [];
if (state.warning.source === "env") {
envSources.push(`${ENV_MAP[warnKey].primary}=${state.warning.value}`);
}
if (state.critical.source === "env") {
envSources.push(`${ENV_MAP[critKey].primary}=${state.critical.value}`);
}
const envDetails = envSources.length > 0 ? ` from ${envSources.join(", ")}` : "";
const explanation = `; inverted pair ${state.rejectedPair.warning}/${state.rejectedPair.critical}${envDetails} falls back to 95/99 defaults in this shell`;
const effectiveValue = key === warnKey ? 95 : 99;
return { effectiveValue, explanation };
}
// These settings remain boot-only; capture interval/line settings reload live.
const BOOT_ONLY_KEYS = ["daemon.port", "daemon.host", "daemon.db_conflict", "daemon.seat_poll_interval_ms", "db.path", "terminal.tmux_server"];
const BOOT_ONLY_PREFIXES = ["transcripts.enabled", "transcripts.path"];
@@ -220,11 +254,16 @@ Precedence: CLI flag > environment variable > config file > default`)
try {
store.set(key, value);
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
: resolved.source === "default"
? `${key} file override saved; effective value ${resolved.value} (source: default)`
: `${key} = ${resolved.value}`);
const fallbackInfo = getContextPressureFallbackInfo(store, key);
if (fallbackInfo) {
console.log(`${key} file override saved; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation})`);
} else {
console.log(resolved.source === "env"
? `${key} = ${resolved.value} (source: env; file override saved but masked)`
: resolved.source === "default"
? `${key} file override saved; effective value ${resolved.value} (source: default)`
: `${key} = ${resolved.value}`);
}
// SWEEP-c: boot-only key + running daemon = stale-until-restart; say so loudly.
if (isBootOnlyKey(key) && (await probeDaemonRunning())) {
console.error(resolved.source === "env"
@@ -246,9 +285,14 @@ Precedence: CLI flag > environment variable > config file > default`)
store.reset(key);
if (key) {
const resolved = store.resolveWithSource(key);
console.log(resolved.source === "env"
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
: `${key} reset to default (${resolved.value}).`);
const fallbackInfo = getContextPressureFallbackInfo(store, key);
if (fallbackInfo) {
console.log(`${key} file override cleared; effective value ${fallbackInfo.effectiveValue} (source: default${fallbackInfo.explanation}).`);
} else {
console.log(resolved.source === "env"
? `${key} file override cleared; effective value ${resolved.value} (source: env; default: ${resolved.defaultValue}).`
: `${key} reset to default (${resolved.value}).`);
}
} else {
let envKeys: string[] = [];
try {
+38 -5
View File
@@ -1319,6 +1319,12 @@ export interface ResolvedSetting {
defaultValue: string | number | boolean;
}
export interface ContextPressurePolicyState {
warning: ResolvedSetting;
critical: ResolvedSetting;
rejectedPair: { warning: number; critical: number } | null;
}
export class ConfigStore {
readonly configPath: string;
@@ -1585,6 +1591,34 @@ export class ConfigStore {
return out;
}
/** Inspect resolved context-pressure policy and whether an inverted pair was rejected. */
resolveContextPressurePolicyState(
fileConfig?: Record<string, unknown>,
workspaceRoot?: string,
): ContextPressurePolicyState {
const fc = fileConfig ?? this.readConfigFile();
const wr = workspaceRoot ?? (this.resolveOne("workspace.root", fc, DEFAULT_WORKSPACE_ROOT).value as string);
return this.resolveContextPressureStateRaw(fc, wr);
}
private resolveContextPressureStateRaw(
fileConfig: Record<string, unknown>,
workspaceRoot: string,
): ContextPressurePolicyState {
const warningKey = "health.context_pressure.warning_percent" as const;
const criticalKey = "health.context_pressure.critical_percent" as const;
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
if ((warning.value as number) < (critical.value as number)) {
return { warning, critical, rejectedPair: null };
}
return {
warning,
critical,
rejectedPair: { warning: warning.value as number, critical: critical.value as number },
};
}
private resolveOne(key: ValidKey, fileConfig: Record<string, unknown>, workspaceRoot: string): ResolvedSetting {
if (key === "health.context_pressure.warning_percent" || key === "health.context_pressure.critical_percent") {
return this.resolveContextPressurePair(fileConfig, workspaceRoot)[key];
@@ -1598,13 +1632,12 @@ export class ConfigStore {
): Record<"health.context_pressure.warning_percent" | "health.context_pressure.critical_percent", ResolvedSetting> {
const warningKey = "health.context_pressure.warning_percent" as const;
const criticalKey = "health.context_pressure.critical_percent" as const;
const warning = this.resolveOneUnpaired(warningKey, fileConfig, workspaceRoot);
const critical = this.resolveOneUnpaired(criticalKey, fileConfig, workspaceRoot);
if ((warning.value as number) < (critical.value as number)) {
return { [warningKey]: warning, [criticalKey]: critical };
const state = this.resolveContextPressureStateRaw(fileConfig, workspaceRoot);
if (!state.rejectedPair) {
return { [warningKey]: state.warning, [criticalKey]: state.critical };
}
process.stderr.write(
`[openrig-config] context-pressure policy rejected: warning (${warning.value}) must be less than critical (${critical.value}); falling back to 95/99 defaults\n`,
`[openrig-config] context-pressure policy rejected: warning (${state.warning.value}) must be less than critical (${state.critical.value}); falling back to 95/99 defaults\n`,
);
return {
[warningKey]: { value: 95, source: "default", defaultValue: 95 },
+103 -40
View File
@@ -503,6 +503,28 @@ const READ_ONLY: ToolAnnotations = { readOnlyHint: true };
const ADDITIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: false };
const DESTRUCTIVE: ToolAnnotations = { readOnlyHint: false, destructiveHint: true };
/** The title and plain first sentence a connected assistant's client shows a person for each tool. Each replaces only the
* opening it names; every rule after it stays as the model reads it. */
export const PLAIN_WORDING: Readonly<Record<string, { title: string; replaces: string; with: string }>> = {
rig_queue_list: { title: "List my OpenRig requests", replaces: "List queue items.",
with: "Lists the requests addressed to this assistant, or that it sent." },
rig_queue_show: { title: "Show one request", replaces: "Read one queue item and its transitions.",
with: "Shows one request, who picked it up, and its answer." },
rig_queue_create: { title: "Send a request", replaces: "Hand a task to another seat as a durable queue item.",
with: "Sends a request to another assistant on your team." },
rig_queue_claim: { title: "Pick up a request", replaces: "Claim a queue item assigned to this seat before working on it.",
with: "Marks a request addressed to this assistant as picked up, so others see it's being worked on. Do it before working on the request." },
rig_queue_update: { title: "Answer a request", replaces: "Update a queue item: add a note, block it, close it, or reopen a closed one.",
with: "Adds this assistant's answer to a request, or a note while it works. It can also block, close or reopen one." },
rig_artifact_read: { title: "Read a shared file",
replaces: "Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text:",
with: "Reads a file attached to a request. It returns text:" },
rig_team: { title: "Show my team", replaces: "Show a rig's team.",
with: "Shows the assistants on your team, each one's address for sending it a request, and which one leads." },
rig_whoami: { title: "Show this connection", replaces: "Show which OpenRig seat this server acts as: ",
with: "Shows which assistant this connection is and which team it belongs to. It answers with " },
};
/** Who an authenticated HTTP caller must be to be offered a tool: work tools (the work loop, orientation, and
* communication with seats) reach a team within the caller's scope; workspace tools read the work tree and context when
* the caller's grant includes them; owner tools administer the instance (launch, stop, snapshot and restore, discover and
@@ -662,25 +684,41 @@ function instructionsFor(access: McpAccess | undefined, frontDoor = false): stri
return tools.has("rig_team") ? `Discover the team this grant connects you to by calling rig_team without rigId. A sole granted rig is selected automatically; with several, it returns the granted names to choose from.\n${instructions}` : instructions;
}
/** A configured default must identify exactly one existing local node. No topology relationship, name convention
* for a lead, caller identity or task history supplies a missing receiver. Resolve again on each omitted destination. */
async function taskDestinationExists(client: DaemonClient, destination: string): Promise<boolean> {
const rigName = destination.split("@")[1]!;
/** The seats of `rigName` that `name` names, each as its actual session: its node's canonical session, or the
* pod-member form while the node has no session yet. A name matches, in this order, a seat's session's own name before
* `@`, its pod-member form (logical id `pod.member` written `pod-member`), or that own name's slug, so a seat attached
* under its own name is found by the name its team shows. Only seats the caller may address (`addressable`) count, and
* the first of these that any of them matches decides: another seat's slug never makes an exact name ambiguous, and a
* seat the caller can't address never decides or shadows one it can. No topology relationship, name convention for a
* lead, caller identity or task history supplies a seat. Throws when the rig isn't one active rig here or its node list
* can't be read. */
async function namedSeats(client: DaemonClient, rigName: string, name: string, addressable: (session: string) => boolean): Promise<string[]> {
const rig = await resolveRigHandle(client, rigName, { strict: true });
if (rig.kind !== "resolved") return false;
if (rig.kind !== "resolved") throw new Error(`rig ${rigName} isn't one active rig here`);
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return false;
let matches = 0;
if (nodes.status !== 200 || !Array.isArray(nodes.data)) throw new Error(`the node list for ${rigName} answered HTTP ${nodes.status}`);
const wanted = seatSlug(name);
const [byOwn, byPodMember, bySlug] = [new Set<string>(), new Set<string>(), new Set<string>()];
for (const entry of nodes.data as unknown[]) {
const { logicalId, rigName: name } = (entry ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || name !== rigName) return false;
const { logicalId, rigName: inRig, canonicalSessionName } = (entry ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || inRig !== rigName) throw new Error(`the node list for ${rigName} has a malformed entry`);
const dot = logicalId.indexOf(".");
if (dot <= 0 || dot === logicalId.length - 1) return false;
if (`${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${name}` === destination) matches++;
if (dot <= 0 || dot === logicalId.length - 1) throw new Error(`the node list for ${rigName} has a malformed entry`);
const podMember = `${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}`;
const session = typeof canonicalSessionName === "string" && canonicalSessionName.endsWith(`@${rigName}`)
&& canonicalSessionName.length > rigName.length + 1 ? canonicalSessionName : `${podMember}@${rigName}`;
const own = session.slice(0, -(rigName.length + 1));
if (!addressable(session)) continue;
if (name === own) byOwn.add(session);
else if (name === podMember) byPodMember.add(session);
else if (wanted !== "" && wanted === seatSlug(own)) bySlug.add(session);
}
return matches === 1;
return [...(byOwn.size > 0 ? byOwn : byPodMember.size > 0 ? byPodMember : bySlug)];
}
/** A seat name as a slug: lowercase, with each run of other characters a single hyphen. */
const seatSlug = (name: string): string => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
/**
* Maps a DaemonResponse to MCP tool result.
* Three error levels: HTTP status, in-body errors, and structural failure fields.
@@ -804,6 +842,15 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
return kind === "work" || (kind === "workspace" && access.workspace);
};
const scope = access && !access.owner ? scopeFor(access, seatOf) : null;
/** A tool's title and plain opening for a connected assistant (an external grant), whose client shows a person these;
* rig_whoami's are true for every caller, so it has them everywhere. Another caller keeps the tool's own text, which
* covers what only it can do (listing its rig's work, reaching a person, reading a named shared file). The same title
* goes in both of MCP's title fields, and the hints are unchanged. */
const worded = (tool: string, description: string, annotations: ToolAnnotations): { title?: string; description: string; annotations: ToolAnnotations } => {
const plain = PLAIN_WORDING[tool];
if (!plain || !(access?.external || tool === "rig_whoami") || !description.startsWith(plain.replaces)) return { description, annotations };
return { title: plain.title, description: plain.with + description.slice(plain.replaces.length), annotations: { ...annotations, title: plain.title } };
};
const caller = access ? { authenticated: true, principal: access.principal } : { authenticated: false, identity: "declared" };
/** For an authenticated non-owner: the error for an item it can't reach (or can't read), else null. `owner` asks
* whether it may change the item, which needs the item's owner in scope. */
@@ -1351,10 +1398,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_create")) server.registerTool(
"rig_queue_create",
{
description: "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.",
...worded("rig_queue_create", "Hand a task to another seat as a durable queue item. Returns its qitemId: read the answer later with rig_queue_show. If the call times out, check rig_queue_show with the same qitemId before retrying; never retry with a new id. To ask for a revision of earlier work, name it in revises: the new item opens with 'Revision of <id>' and records it in its chainOfRecord.", ADDITIVE),
inputSchema: z.object({
destination: access?.external
? z.string().optional().describe("Destination seat: member@rig, or its name alone (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
? z.string().optional().describe("Destination seat: member@rig, or its name alone as the team shows it (e.g. workshop-lead) within this connection's team; omit to use the connected team's configured receiver")
: z.string().describe("Destination seat (e.g. dev-impl@my-rig)"),
body: z.string().describe("The task: what to do and what to return"),
summary: z.string().optional().describe("One-line summary"),
@@ -1365,7 +1412,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
revises: z.string().optional().describe("The qitemId of the work this asks to revise"),
alert: z.boolean().optional().describe("For an ask to a person that's urgent: something only they can do, or an approval whose wait costs now (as rig queue create --alert). It stands out in Slack. Without it the ask still posts to Slack unless the person's Slack subscription is urgent-only, in which case it waits on Steering and the TUI"),
}),
annotations: ADDITIVE,
},
async ({ destination, body, summary, tags, priority, tier, qitemId, revises, alert }) => {
// The server's one receiver counts for this connection only when its grant reaches it. Then a refused destination
@@ -1381,22 +1427,46 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
// Check scope before looking up the configured receiver: configuration never expands a grant or reveals
// another rig's destination. An explicit destination continues through the existing path below.
if (!scope?.destination(destination)) return outOfScope("Configured task destination");
// The receiver is exactly one seat this connection may address, and the item goes to that seat's actual session.
const at = destination.lastIndexOf("@");
let seats: string[];
try {
if (!await taskDestinationExists(client, destination)) return unavailable();
seats = await namedSeats(client, destination.slice(at + 1), destination.slice(0, at), (seat) => scope?.destination(seat) === true);
} catch { return unavailable(); }
if (seats.length !== 1) return unavailable();
destination = seats[0]!;
} else if (destination !== undefined && access?.external && !destination.includes("@")) {
// A person names their lead as their team shows it ("workshop-lead"), and the assistant passes that on. A name
// alone is the receiver this connection reaches when it is that seat's name, else a seat of this connection's one
// rig; never another rig's. It must be in scope and exist, or nothing is written.
// A person names a seat as their team shows it ("workshop-lead"), and the assistant passes that on. A name alone
// looks in the rig of the receiver this connection reaches when it is that receiver's name, else in this
// connection's one rig; never another rig's. It reaches one seat the connection may address, stored under that
// seat's actual session, or nothing is written.
const named = destination;
const resolved = receiver?.split("@")[0] === named ? receiver
: access.rigs.length === 1 ? `${named}@${access.rigs[0]}` : undefined;
let exists = false;
if (resolved && scope?.destination(resolved)) {
try { exists = await taskDestinationExists(client, resolved); } catch { /* not resolved */ }
const receiverAt = receiver?.lastIndexOf("@") ?? -1;
const rigName = receiver && receiver.slice(0, receiverAt) === named ? receiver.slice(receiverAt + 1)
: access.rigs.length === 1 ? access.rigs[0] : undefined;
if (!rigName) return outOfScope(`Destination ${named}`, omitHint);
// A name this connection may not address as written (another caller's seat without team/list, or its own) is
// refused before the team is read, so the answer doesn't say whether such a seat exists.
if (named !== "" && !scope?.destination(`${named}@${rigName}`)) return outOfScope(`Destination ${named}`, omitHint);
let reachable: string[];
try {
reachable = named === "" ? [] : await namedSeats(client, rigName, named, (seat) => scope?.destination(seat) === true);
} catch {
const message = "This connection's team couldn't be read just now, so nothing was written. Try again.";
return errorResult(message, "destination_unchecked", message);
}
if (!exists) return outOfScope(`Destination ${named}`, omitHint);
destination = resolved!;
if (reachable.length > 1) {
const message = `'${named}' names ${reachable.length} seats here. Nothing was written; pass one of these: ${reachable.join(", ")}.`;
return errorResult(JSON.stringify({ ok: false, code: "destination_ambiguous", message, candidates: reachable }),
"destination_ambiguous", message, { candidates: reachable });
}
// A seat it may not address reads as no seat at all, so a hidden seat's name says nothing about it.
if (reachable.length === 0) {
const where = offered("rig_team") ? " rig_team lists the seats you can reach and their names." : " Check the name the person gave.";
const message = `No seat here is named '${named}', so nothing was written.${where}${omitHint ? ` ${omitHint}` : ""}`;
return errorResult(message, "destination_not_found", message);
}
destination = reachable[0]!;
}
if (scope && !scope.destination(destination)) return outOfScope(`Destination ${destination}`, access?.external ? omitHint : undefined);
// A revision carries its original's lineage as a handoff does: the original's chain, then the original.
@@ -1463,11 +1533,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_show")) server.registerTool(
"rig_queue_show",
{
description: "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.",
...worded("rig_queue_show", "Read one queue item and its transitions. For a task you handed off, this is where its answer is: the state, the closure reason and target, the notes the assignee recorded, and evidenceRef when it names an artifact (read it with rig_artifact_read). When the item was handed on, successor names the next item (on another host: its id and host); follow it with rig_queue_show. null means none was found. A revision names the work it revises in chainOfRecord.", READ_ONLY),
inputSchema: z.object({
qitemId: z.string().describe("The qitemId returned by rig_queue_create (or any queue item)"),
}),
annotations: READ_ONLY,
},
async ({ qitemId }) => {
try {
@@ -1497,7 +1566,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_list")) server.registerTool(
"rig_queue_list",
{
description: "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.",
...worded("rig_queue_list", "List queue items. owned: assigned to this seat. mine: this seat is the source or the destination. Without a scope, this seat's rig. Without state, open items only (all adds closed ones); a state filter returns exactly those states, closed ones included. since finds work updated after a time; handedOffFrom finds where a handed-off item went. tags are exact, case-sensitive AND filters; mission and slice add their corresponding exact tags.", READ_ONLY),
inputSchema: z.object({
owned: z.boolean().optional().describe("Only items assigned to this seat: its whole inbox (overrides destination)"),
mine: z.boolean().optional().describe("Items this seat sent or owns"),
@@ -1512,7 +1581,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
handedOffFrom: z.string().optional().describe("The items handed on from this qitemId, in any state and any rig"),
limit: z.number().int().positive().optional().describe("Result limit (default 100)"),
}),
annotations: READ_ONLY,
},
async ({ owned, mine: mineAsked, destination, source, state, tags: requestedTags, mission, slice, all, since, handedOffFrom, limit }) => {
try {
@@ -1576,9 +1644,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_claim")) server.registerTool(
"rig_queue_claim",
{
description: "Claim a queue item assigned to this seat before working on it.",
...worded("rig_queue_claim", "Claim a queue item assigned to this seat before working on it.", DESTRUCTIVE),
inputSchema: z.object({ qitemId: z.string().describe("The queue item to claim") }),
annotations: DESTRUCTIVE,
},
async ({ qitemId }) => {
try {
@@ -1598,7 +1665,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_update")) server.registerTool(
"rig_queue_update",
{
description: "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.",
...worded("rig_queue_update", "Update a queue item: add a note, block it, close it, or reopen a closed one. To answer a task, close it with state done, a closure reason and the answer in resolution, which the item keeps; note records why this transition happened.", DESTRUCTIVE),
inputSchema: z.object({
qitemId: z.string().describe("The queue item to update"),
state: z.string().optional().describe("New state (e.g. in-progress, blocked, done)"),
@@ -1612,7 +1679,6 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
blockedOn: z.string().optional().describe("What it waits on, when blocking: another qitemId (this item returns to pending when that one closes), or the address of the person this item came from, not a shared channel (they're asked with this item's summary, and their answer returns it to you in progress)"),
wakeAfterSeconds: z.number().int().positive().optional().describe("Wake this seat after this many seconds, when blocking. Delivered only to a seat with a terminal: an attached app can't receive it today, so the item just waits until it is checked."),
}),
annotations: DESTRUCTIVE,
},
async ({ qitemId, state, closureReason, closureTarget, note, resolution, reopen, summary, evidenceRef, blockedOn, wakeAfterSeconds }) => {
try {
@@ -1699,9 +1765,8 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_whoami")) server.registerTool(
"rig_whoami",
{
description: "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).",
...worded("rig_whoami", "Show which OpenRig seat this server acts as: its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).", READ_ONLY),
inputSchema: z.object({}),
annotations: READ_ONLY,
},
async () => {
try {
@@ -1756,11 +1821,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_team")) server.registerTool(
"rig_team",
{
description: "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.",
...worded("rig_team", "Show a rig's team. For an external grant, omit rigId to use its sole rig or see its granted names to choose from. The answer includes its lead, the rig's one delegates_to root (the seat that delegates to others and that nothing delegates to: the seat to delegate to), and each seat's pod, role, runtime, delegation (reportsTo, delegatesTo) and activity as the daemon reports it. Activity the daemon doesn't know reads unknown, never idle. hostedMembers are the rig's members that live outside a terminal (roster entries marked hosted): their authored capabilities and limits, with no pane and activity unknown.", READ_ONLY),
inputSchema: z.object({
rigId: z.string().optional().describe("Rig name or identifier; optional for an external grant to discover its team"),
}),
annotations: READ_ONLY,
},
async ({ rigId }) => {
try {
@@ -1933,14 +1997,13 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_artifact_read")) server.registerTool(
"rig_artifact_read",
{
description: `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`,
...worded("rig_artifact_read", `Read a finished item's artifact (its evidenceRef), or a named file, through the daemon's shared roots (OPENRIG_FILES_ALLOWLIST), as text: at most ${CONTEXT_TEXT_LIMIT} characters from offset; nextOffset reads on. An artifact outside every shared root, a link, or an item with no evidenceRef comes back as available: false with the reason and the shared roots' names; nothing outside those roots is read. A code-result manifest (kind openrig.code-result/v1) also comes back parsed as codeResult: its repo, the exact base and revision commits, and paths; a branch or fetch ref in it is a hint, never the revision.`, READ_ONLY),
inputSchema: z.object({
qitemId: z.string().optional().describe("Read this item's evidenceRef"),
root: z.string().optional().describe("A shared root's name: with path, the file to read; with qitemId, the root a relative evidenceRef is under"),
path: z.string().optional().describe("With root: the file's path inside that root"),
offset: z.number().int().min(0).optional().describe("The character to start from (default 0)"),
}),
annotations: READ_ONLY,
},
async ({ qitemId, root, path, offset }) => {
try {
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { configCommand } from "../src/commands/config.js";
import { ConfigStore } from "../src/config-store.js";
describe("issue #2529: context-pressure inverted environment pair reporting", () => {
let root: string;
let configPath: string;
let savedEnv: Record<string, string | undefined>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "opr-issue-2529-"));
configPath = path.join(root, "config.json");
savedEnv = {
OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"],
OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT: process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"],
};
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"];
delete process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"];
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = undefined;
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fs.rmSync(root, { recursive: true, force: true });
});
it("persists valid file value and reports inverted environment pair with env sources", async () => {
// Scenario 2 from issue #2529:
// warning is 98 in env. Setting critical to 97 in file.
// The write succeeds, persists critical 97 to file, but reports the inverted pair
// and that it falls back to 95/99 defaults in this shell.
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98";
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toContain(
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 98/97 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=98 falls back to 95/99 defaults in this shell)",
);
stderr.mockRestore();
});
it("persists and prints cleanly without warning when there is no environment conflict", async () => {
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toBe("health.context_pressure.critical_percent = 100");
});
it("whole-file reset still works even if config file is malformed JSON", async () => {
fs.writeFileSync(configPath, "{ not valid json ... ");
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "reset"]);
expect(fs.existsSync(configPath)).toBe(false);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toBe("Config reset to defaults.");
});
it("reports inverted pair on reset <key> when active environment override causes fallback", async () => {
// File has warning 90, critical 98
const store = new ConfigStore(configPath);
store.set("health.context_pressure.warning_percent", "90");
store.set("health.context_pressure.critical_percent", "98");
// Environment sets warning=99
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "99";
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const cmd = configCommand(configPath);
// Resetting critical removes file 98, so file has warning 90 / default critical 99.
// But with env warning=99, env warning (99) >= default critical (99), falling back to 95/99.
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toContain(
"health.context_pressure.critical_percent file override cleared; effective value 99 (source: default; inverted pair 99/99 from OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT=99 falls back to 95/99 defaults in this shell).",
);
stderr.mockRestore();
});
it("resets cleanly to default without inversion warning when no environment conflict exists", async () => {
const store = new ConfigStore(configPath);
store.set("health.context_pressure.critical_percent", "100");
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "reset", "health.context_pressure.critical_percent"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBeUndefined();
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toBe("health.context_pressure.critical_percent reset to default (99).");
expect(output).not.toContain("inverted");
});
it("handles rejected partial environment override without false inversion warning", async () => {
// Warning env is partial/malformed '98junk', setting critical to 97 on an empty file.
// The resolver discards '98junk' (falls back to default 95), so pair is 95/97 (valid).
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "98junk";
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toBe("health.context_pressure.critical_percent = 97");
expect(output).not.toContain("inverted");
stderr.mockRestore();
});
it("handles rejected out-of-range environment override without false inversion warning", async () => {
// Warning env is out-of-range '101', setting critical to 97 on an empty file.
// The resolver discards '101' (falls back to default 95), so pair is 95/97 (valid).
// The write saves 97, effective is 97 from file, and no inversion/fallback is reported.
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_WARNING_PERCENT"] = "101";
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "97"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(97);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toBe("health.context_pressure.critical_percent = 97");
expect(output).not.toContain("inverted");
stderr.mockRestore();
});
it("correctly explains fallback when an invalid file value is discarded and causes inversion", async () => {
// File has invalid warningPercent: 0, critical env is 90.
// Writing critical 100 to file saves critical 100.
// The resolver discards file warningPercent 0, uses default 95.
// With critical env 90, warning 95 >= critical 90 causes inversion and fallback to 95/99.
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
JSON.stringify({ health: { contextPressure: { warningPercent: 0 } } }, null, 2) + "\n",
);
process.env["OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT"] = "90";
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const cmd = configCommand(configPath);
await cmd.parseAsync(["node", "rig", "set", "health.context_pressure.critical_percent", "100"]);
const fileContent = JSON.parse(fs.readFileSync(configPath, "utf8"));
expect(fileContent.health?.contextPressure?.criticalPercent).toBe(100);
const output = logSpy.mock.calls.map((c) => c.join(" ")).join("\n");
expect(output).toContain(
"health.context_pressure.critical_percent file override saved; effective value 99 (source: default; inverted pair 95/90 from OPENRIG_HEALTH_CONTEXT_PRESSURE_CRITICAL_PERCENT=90 falls back to 95/99 defaults in this shell)",
);
stderr.mockRestore();
});
});
@@ -119,7 +119,7 @@ describe("rig config get --with-source", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
try {
expect(await run(["set", "health.context_pressure.critical_percent", "97"]))
.toContain("file override saved; effective value 99 (source: default)");
.toContain("file override saved; effective value 99 (source: default");
expect(JSON.parse(fs.readFileSync(configPath, "utf8")).health.contextPressure.criticalPercent).toBe(97);
expect(stderr).toHaveBeenCalled();
} finally { stderr.mockRestore(); }
@@ -156,14 +156,14 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
const args = { qitemId: "qitem-first-task", body: "Create a useful file and return it" };
const first = await ask("rig_queue_create", args);
expect(first.isError).toBe(false);
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a", body: args.body });
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a", body: args.body });
const retry = await ask("rig_queue_create", args);
expect(retry.isError).toBe(false);
expect(retry.structured.qitemId).toBe(args.qitemId);
expect(rows()).toBe(1);
const revision = await ask("rig_queue_create", { body: "Improve the file", revises: args.qitemId });
expect(revision.isError).toBe(false);
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a",
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a",
chainOfRecord: [args.qitemId], body: `Revision of ${args.qitemId}.\n\nImprove the file` });
// A supplied destination still takes the original route, even with a different configured receiver.
expect((await ask("rig_queue_create", { destination: "other@team-a", body: "Explicit task" })).isError).toBe(false);
@@ -185,7 +185,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
expect(tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.required).not.toContain("destination");
const result = await client.callTool({ name: "rig_queue_create", arguments: { qitemId: "qitem-modern-first", body: "Create the file" } });
expect(result.isError).not.toBe(true);
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
});
it("does not infer a receiver from grant metadata, caller identity or an empty queue", async () => {
@@ -219,16 +219,129 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
const ask = await connect(url);
const sent = await ask("rig_queue_create", { qitemId: "qitem-bare-name", destination: "pod-lead", body: "Do this directly" });
expect(sent.isError).toBe(false);
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
// A name that is no seat there, or the caller's own, writes nothing.
for (const name of ["my-lead", "cus-vvmaexample01-caller", ""]) {
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("out_of_scope");
expect(repo.getById("qitem-bare-name")).toMatchObject({ sourceSession: CALLER, destinationSession: "lead@team-a" });
// A name that is no seat there says so; the caller's own seat stays a scope refusal. Neither writes anything.
for (const name of ["my-lead", ""]) {
expect((await ask("rig_queue_create", { destination: name, body: "Nobody" })).structured.code).toBe("destination_not_found");
}
expect((await ask("rig_queue_create", { destination: "cus-vvmaexample01-caller", body: "Myself" })).structured.code).toBe("out_of_scope");
expect(rows()).toBe(1);
const described = (await ask.catalog()).tools.find((tool) => tool.name === "rig_queue_create")?.inputSchema.properties?.destination as { description?: string };
expect(described.description).toContain("name alone");
});
describe("a name alone reaches the seat's actual session", () => {
/** A seat whose session is its own name, as a self-attached seat's is, rather than its pod-member form. */
function namedSeat(rigName: string, member: string, session: string) {
const rig = setup.rigRepo.listRigs?.().find((r: { name: string }) => r.name === rigName) ?? setup.rigRepo.createRig(rigName);
const node = setup.rigRepo.addNode(rig.id, `pod.${member}`, { role: "worker" });
setup.sessionRegistry.registerSession(node.id, session);
}
it("finds a seat by its session's own name, that name's slug, or its pod-member form, and stores its actual session", async () => {
namedSeat("team-a", "caller-7f3a", "Workshop.Caller@team-a");
const { url } = await listen();
const ask = await connect(url);
for (const [name, qitemId] of [["Workshop.Caller", "qitem-own-name"], ["workshop-caller", "qitem-slug"], ["pod-caller-7f3a", "qitem-pod-member"]]) {
const sent = await ask("rig_queue_create", { qitemId, destination: name, body: `Reach me as ${name}` });
expect(sent.isError).toBe(false);
expect(repo.getById(qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "Workshop.Caller@team-a" });
}
expect(rows()).toBe(3);
});
it("names the candidates when a name matches no seat exactly and two by slug, and writes nothing", async () => {
namedSeat("team-a", "reviewer", "reviewer@team-a");
namedSeat("team-a", "reviewer-two", "Reviewer@team-a");
const { url } = await listen();
const ask = await connect(url);
const refused = await ask("rig_queue_create", { destination: "REVIEWER", body: "Which one?" });
expect(refused.structured).toMatchObject({ code: "destination_ambiguous" });
expect([...refused.structured.candidates].sort()).toEqual(["Reviewer@team-a", "reviewer@team-a"]);
expect(rows()).toBe(0);
});
it("says no such seat, naming rig_team when the grant offers it, apart from a scope refusal and an unreadable team", async () => {
namedSeat("team-a", "lead", "lead@team-a");
answer = () => [200, active({ objects: { queue: ["create", "show", "list"], team: ["list"] } })];
const { url } = await listen();
const ask = await connect(url);
const missing = await ask("rig_queue_create", { destination: "led", body: "Typo" });
expect(missing.structured).toMatchObject({ code: "destination_not_found" });
expect(missing.text).toContain("No seat here is named 'led'");
expect(missing.text).toContain("rig_team lists the seats you can reach");
broken = (req) => req.method === "GET" && /\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)
? new Response(JSON.stringify({ error: "boom" }), { status: 500, headers: { "Content-Type": "application/json" } }) : undefined;
expect((await ask("rig_queue_create", { destination: "lead", body: "Unread" })).structured).toMatchObject({ code: "destination_unchecked" });
broken = undefined;
expect(rows()).toBe(0);
expect((await ask("rig_queue_create", { destination: "lead", body: "Reached" })).isError).toBe(false);
expect(rows()).toBe(1);
});
it("an exact name wins over another seat's slug twin, as a name alone and as the configured receiver", async () => {
namedSeat("team-a", "lead", "pod-lead@team-a");
namedSeat("team-a", "other", "Pod.Lead@team-a");
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-name", destination: "pod-lead", body: "Exact" })).isError).toBe(false);
expect((await ask("rig_queue_create", { qitemId: "qitem-exact-receiver", body: "Receiver" })).isError).toBe(false);
for (const qitemId of ["qitem-exact-name", "qitem-exact-receiver"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "pod-lead@team-a" });
});
it("a pod-member name that reached one seat still does beside a self-attached slug twin", async () => {
namedSeat("team-a", "lead", "lead@team-a");
namedSeat("team-a", "twin", "Pod_Lead@team-a");
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-pod-member-kept", destination: "pod-lead", body: "As before" })).isError).toBe(false);
expect(repo.getById("qitem-pod-member-kept")).toMatchObject({ destinationSession: "lead@team-a" });
});
it("without team/list, another caller's name answers the same whether or not its seat exists, before the team is read", async () => {
let nodeReads = 0;
broken = (req) => { if (/\/api\/rigs\/[^/]+\/nodes$/.test(new URL(req.url).pathname)) nodeReads++; return undefined; };
const { url } = await listen();
const ask = await connect(url);
const answers = async () => Promise.all(["cus-other-connection", "Cus_Other_Connection"].map(async (destination) =>
(await ask("rig_queue_create", { destination, body: "Hidden?" })).structured));
const absent = await answers();
namedSeat("team-a", "other-connection", "cus-other-connection@team-a");
const present = await answers();
expect(present).toEqual(absent);
// As written it's out of scope, without a read; a slug of it reads as no seat at all.
expect(absent.map((answer) => answer.code)).toEqual(["out_of_scope", "destination_not_found"]);
expect(nodeReads).toBe(2);
expect(rows()).toBe(0);
});
it("a seat the grant can't address never shadows one it can, by name alone", async () => {
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
const { url } = await listen();
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-alone", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
// Another caller's seat whose pod-member form is the name exactly: hidden without team/list, so it doesn't count.
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
expect((await ask("rig_queue_create", { qitemId: "qitem-slug-beside-hidden", destination: "pod-hidden", body: "Slug" })).isError).toBe(false);
for (const qitemId of ["qitem-slug-alone", "qitem-slug-beside-hidden"]) expect(repo.getById(qitemId)).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
});
it("a configured receiver that names only a hidden seat is unavailable, as when it names none, and never names that seat", async () => {
const { url } = await listen(undefined, "pod-hidden@team-a");
const ask = await connect(url);
const none = await ask("rig_queue_create", { body: "Receiver?" });
namedSeat("team-a", "hidden", "cus-other-connection@team-a");
const hidden = await ask("rig_queue_create", { body: "Receiver?" });
expect(hidden.structured).toEqual(none.structured);
expect(hidden.structured.code).toBe("task_destination_unavailable");
expect(JSON.stringify(hidden)).not.toContain("cus-other-connection");
expect(rows()).toBe(0);
namedSeat("team-a", "visible", "Pod_Hidden@team-a");
expect((await ask("rig_queue_create", { qitemId: "qitem-receiver-beside-hidden", body: "Receiver" })).isError).toBe(false);
expect(repo.getById("qitem-receiver-beside-hidden")).toMatchObject({ destinationSession: "Pod_Hidden@team-a" });
});
});
it("with several rigs, a name alone reaches only the configured receiver of that name; a refusal says to omit destination, without naming it", async () => {
seat("team-a", "lead");
seat("team-a", "other"); // A seat of one of the rigs: with several, its name alone still doesn't pick that rig.
@@ -236,7 +349,7 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
const { url } = await listen(undefined, "pod-lead@team-a");
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-bare-receiver", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "pod-lead@team-a" });
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "lead@team-a" });
for (const destination of ["pod-other", "my-lead", "pod-lead@team-c"]) {
const refused = await ask("rig_queue_create", { destination, body: "Ambiguous or unknown" });
expect(refused.structured).toMatchObject({ code: "out_of_scope", message: expect.stringContaining("Omit destination to reach the team's configured receiver.") });
@@ -251,9 +364,9 @@ describe("rig mcp serve --http --grant-service on the daemon's routes", () => {
const { url } = await listen(undefined, "pod-lead@team-b");
const ask = await connect(url);
expect((await ask("rig_queue_create", { qitemId: "qitem-own-lead", destination: "pod-lead", body: "Do this directly" })).isError).toBe(false);
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "pod-lead@team-a" });
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "lead@team-a" });
const refused = await ask("rig_queue_create", { destination: "my-lead", body: "Nobody" });
expect(refused.structured.code).toBe("out_of_scope");
expect(refused.structured.code).toBe("destination_not_found");
expect(refused.structured.message).not.toContain("Omit destination");
expect(rows()).toBe(1);
});
@@ -96,7 +96,8 @@ describe("MCP operation table against the daemon", () => {
external: { objects: { question: ["read", "answer"] }, person: "ada" } } });
const personTools = (await asPerson.listTools()).tools;
expect(personTools.map((tool) => tool.name).sort()).toEqual(MCP_OPERATIONS.filter((op) => op.access === "person").map((op) => op.tool).sort());
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, tool.annotations]));
// A title is a display name, not a hint.
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
for (const op of MCP_OPERATIONS) {
expect(hints[op.tool], op.tool).toEqual(HINTS[op.effect]);
// Every tool says who an authenticated HTTP caller must be to be offered it.
+51 -2
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
import { createMcpServer, dropUnfilledSeat, mintUnknownSender, FRONT_DOOR_WORKFLOW, FRONT_DOOR_WORKFLOW_VERSION, MEMBER_WORKFLOW, MEMBER_WORKFLOW_VERSION, PLAIN_WORDING, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
import { DaemonClient, DaemonConnectionError, senderIdentityHeaders, type DaemonResponse } from "../src/client.js";
function mockClient(overrides?: {
@@ -246,7 +246,8 @@ describe("MCP Server", () => {
// A delegated decision closes the person's ask; a handoff closes the item passed on; an unclaim ends a claim.
const destructive = ["rig_bind", "rig_discover", "rig_down", "rig_queue_claim", "rig_queue_decide", "rig_queue_handoff", "rig_queue_unclaim",
"rig_queue_update", "rig_restore", "rig_send", "rig_up"];
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
// A title is a display name, not a hint (the plain-wording test checks it).
const hints = Object.fromEntries(tools.map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
expect(Object.keys(hints).sort()).toEqual([...reads, ...additive, ...destructive].sort());
for (const name of reads) expect(hints[name], name).toEqual({ readOnlyHint: true });
for (const name of additive) expect(hints[name], name).toEqual({ readOnlyHint: false, destructiveHint: false });
@@ -881,6 +882,54 @@ describe("MCP Server", () => {
await server.close();
});
it("shows a connected assistant's person plain titles and openings, keeping each tool's rules and hints", async () => {
// What a person sees: each tool's title and the first sentence of its description.
const plain: Record<string, [title: string, sentence: string]> = {
rig_queue_list: ["List my OpenRig requests", "Lists the requests addressed to this assistant, or that it sent."],
rig_queue_show: ["Show one request", "Shows one request, who picked it up, and its answer."],
rig_queue_create: ["Send a request", "Sends a request to another assistant on your team."],
rig_queue_claim: ["Pick up a request", "Marks a request addressed to this assistant as picked up, so others see it's being worked on."],
rig_queue_update: ["Answer a request", "Adds this assistant's answer to a request, or a note while it works."],
rig_artifact_read: ["Read a shared file", "Reads a file attached to a request."],
rig_team: ["Show my team", "Shows the assistants on your team, each one's address for sending it a request, and which one leads."],
rig_whoami: ["Show this connection", "Shows which assistant this connection is and which team it belongs to."],
};
expect(Object.keys(PLAIN_WORDING).sort()).toEqual(Object.keys(plain).sort());
const catalog = async (options?: Parameters<typeof createMcpServer>[1]) => {
const server = createMcpServer(mockClient(), options);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "catalog-client", version: "1.0.0" });
await server.connect(serverTransport);
await client.connect(clientTransport);
const { tools } = await client.listTools();
await client.close();
await server.close();
return new Map(tools.map((tool) => [tool.name, tool]));
};
const local = await catalog();
const external = await catalog({ seat: "cus-1-caller@team", access: { principal: "cus-1", owner: false, rigs: ["team"], roots: [], workspace: false,
external: { objects: { queue: ["create", "show", "list", "claim", "update"], team: ["list"], artifact: ["read"] } } } });
for (const [name, [title, sentence]] of Object.entries(plain)) {
const own = local.get(name)!;
// rig_whoami isn't offered to an external grant, and its plain text is true for every caller, so the local one shows it.
const shown = name === "rig_whoami" ? own : external.get(name)!;
expect(shown.title, name).toBe(title);
expect(shown.annotations?.title, name).toBe(title);
expect(shown.description?.slice(0, sentence.length + 1), name).toBe(`${sentence} `);
if (name === "rig_whoami") continue;
// Only the opening changes: every rule after it, and each hint, is the tool's own.
const { replaces, with: opening } = PLAIN_WORDING[name]!;
expect(own.description?.startsWith(replaces), name).toBe(true);
expect(shown.description, name).toBe(opening + own.description!.slice(replaces.length));
expect({ ...shown.annotations, title: undefined }, name).toEqual(own.annotations);
// A local caller keeps the tool's own text, which says what only it can do.
expect(own.title, name).toBeUndefined();
expect(own.annotations?.title, name).toBeUndefined();
}
expect(local.get("rig_whoami")?.description).toBe("Shows which assistant this connection is and which team it belongs to. It answers with its rig, pod, peers and edges, and caller: authenticated (a validated HTTP credential, with its principal) or declared (OPENRIG_SESSION_NAME where the server was configured, which nothing verifies).");
expect(local.get("rig_whoami")?.annotations).toEqual({ readOnlyHint: true, title: "Show this connection" });
});
it("rig_down calls POST /api/down with correct params", async () => {
const postFn = vi.fn(async () => ({
status: 200,
@@ -1278,7 +1278,8 @@ reaching them is ordinary work rather than an escalation.
- **The `relay` gateway connector** carries a registered person's questions, updates and notices to a
bridge process you run, over loopback HTTP, and takes their replies back with the sender resolved
against their bindings. It's off until a person has a relay binding (`rig gateway human add <id> …
--binding "relay:<connectorRef>:file:<absolute env file>:primary"`), and the bridge, with its reach to
--binding "relay:<connectorRef>:file:<absolute env file>:primary:handle=<alias>"`). The handle is how
their replies are matched to them: a binding without one is outbound-only. The bridge, with its reach to
any messaging platform, is yours (`docs/reference/gateway-relay.md`).
- **`rig gateway channel add <id> --display-name <name> --binding
slack:<connectorRef>:<secretsRef>:<channelId>`** — a Slack shared channel as one participant,
@@ -1597,8 +1598,11 @@ your circumstances is configuration, and the ones that are not, another agent ca
without that storage), so don't copy them by hand; read the `plugin_skill_*` warnings if one
is missing. TDD is no longer a default skill.
- **`on_restore: follow_source`** on a spec's `resources.skills[]` entry refreshes that skill from its source when
a Claude or Codex seat is restored, keeping its conversation; `keep_saved`, the default, keeps the saved copy, but a
skill whose source changed after `rig down` comes back missing, named in the restore's warnings.
a Claude or Codex seat is restored, keeping its conversation, while the seat's copy is still what OpenRig wrote: an
edited, unrecorded or linked copy is kept and named, and a source that can't be read leaves the saved copy.
`keep_saved`, the default, keeps the saved copy, but a missing file comes back only from a source that still has the
bytes OpenRig wrote: a file whose source changed after `rig down` stays missing, named in the restore's warnings,
even when the rest of the skill (its `SKILL.md`, say) is unchanged.
- **`rig startup-proof submit`** — answer an explicitly selected authenticated startup challenge.
Startup adds no proof exercise by default. Declare `startup_proof` with `authenticated` or
`none` in the applicable startup layers; see `docs/reference/rig-spec.md#startup-block/startup-proof-selection`.
+2
View File
@@ -12,6 +12,7 @@ import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
import { networkInstallationsSchema } from "./migrations/163_network_installations.js";
import { queueItemsReplyToIndexSchema } from "./migrations/164_queue_items_reply_to_index.js";
import { queueListReadIndexesSchema } from "./migrations/166_queue_list_read_indexes.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -331,4 +332,5 @@ export const ALL_MIGRATIONS: Migration[] = [
sessionAddressReadIndexesSchema,
networkInstallationsSchema,
queueItemsReplyToIndexSchema,
queueListReadIndexesSchema,
];
@@ -0,0 +1,18 @@
import type { Migration } from "../migrate.js";
/** Bound queue selection by its existing order, and notification reads by their eligible history. */
export const queueListReadIndexesSchema: Migration = {
name: "166_queue_list_read_indexes.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_queue_items_created
ON queue_items(ts_created DESC);
CREATE INDEX IF NOT EXISTS idx_queue_items_active_created
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_created DESC);
CREATE INDEX IF NOT EXISTS idx_queue_items_active_updated
ON queue_items(CASE WHEN state IN ('pending', 'in-progress', 'blocked') THEN 0 ELSE 1 END, ts_updated DESC);
CREATE INDEX IF NOT EXISTS idx_queue_transitions_owner_notification_qitem
ON queue_transitions(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_queue_transitions_archive_owner_notification_qitem
ON queue_transitions_archive(qitem_id, transition_id DESC) WHERE owner_notification_level IS NOT NULL;
`,
};
@@ -1,9 +1,8 @@
import Database from "better-sqlite3";
/** Rows read per slice, and the time a slice may take, before yielding to the event loop. Both are soft: one native
* step or row conversion can take longer. */
const SLICE_ROWS = 64;
const SLICE_MS = 8;
/** Rowids one statement may cover before the read yields to the event loop. Soft: one large row's conversion, native
* I/O or a correlated subquery on a matching row can still make a window take longer. */
const WINDOW_ROWS = 256;
/**
* The main database file when the execution view's candidate read can run cooperatively on its own connection, or
@@ -18,45 +17,66 @@ export function cooperativeReadFile(db: Database.Database): string | null {
return db.pragma("journal_mode", { simple: true }) === "wal" ? main.file : null;
}
/** One cooperative read: its rows, the windows it read, and the longest synchronous stretch it ran between yields. */
export interface CooperativeRead<Row> {
rows: Row[];
windows: number;
longestStretchMs: number;
}
/**
* Reads every row of one SELECT on a new readonly connection to `file`, yielding to the event loop between slices so
* unrelated requests progress during a long scan. One cursor keeps one snapshot for the whole read. The cursor and
* the connection are closed before returning, on every path; a read error is rethrown as-is and no partial rows are
* returned.
* Reads every row of one SELECT over `table` (a table name from code, never input) on a new readonly connection, one
* rowid window at a time, yielding to the event loop between windows so unrelated requests progress during a long
* scan. `sql` reads one window: it binds the window's exclusive lower and inclusive upper rowid first
* (`table.rowid > ? AND table.rowid <= ?`), then `binds`. Each statement steps over at most `windowRows` rowids however
* few rows its filter matches; a cursor that yields between returned rows can scan the whole table in one step when
* matches are rare. One read transaction keeps one snapshot for every window, and rows come back in rowid order. The
* transaction and the connection end before returning, on every path; a read error is rethrown as-is and no partial
* rows are returned.
*/
export async function readRowsCooperatively<Row>(
file: string,
table: string,
sql: string,
binds: unknown[],
open: (file: string) => Database.Database = (path) => new Database(path, { readonly: true, fileMustExist: true }),
): Promise<Row[]> {
windowRows = WINDOW_ROWS,
): Promise<CooperativeRead<Row>> {
let reader: Database.Database | undefined;
let cursor: IterableIterator<unknown> | undefined;
let rows: Row[] = [];
let windows = 0, longestStretchMs = 0, stretchStart = performance.now();
const endStretch = () => { longestStretchMs = Math.max(longestStretchMs, performance.now() - stretchStart); };
let failure: { error: unknown } | undefined;
let cleanupFailure: { error: unknown } | undefined;
try {
reader = open(file);
cursor = reader.prepare(sql).iterate(...binds);
for (;;) {
const sliceStart = performance.now();
let read = 0;
let step = cursor.next();
while (!step.done) {
rows.push(step.value as Row);
if (++read >= SLICE_ROWS || performance.now() - sliceStart >= SLICE_MS) break;
step = cursor.next();
reader.exec("BEGIN");
// The first read takes the snapshot every window shares. Each scalar subquery seeks one end of the rowid b-tree;
// one SELECT of MIN and MAX together would scan a whole index instead.
const { lo, hi } = reader.prepare(`SELECT (SELECT MIN(rowid) FROM ${table}) AS lo, (SELECT MAX(rowid) FROM ${table}) AS hi`)
.get() as { lo: number | null; hi: number | null };
const window = reader.prepare(sql);
if (lo !== null && hi !== null) {
for (let after = lo - 1; after < hi;) {
const through = Math.min(after + windowRows, hi);
for (const row of window.all(after, through, ...binds)) rows.push(row as Row);
windows++;
after = through;
if (after < hi) {
endStretch();
await new Promise<void>((resolve) => setImmediate(resolve));
stretchStart = performance.now();
}
}
if (step.done) break;
await new Promise<void>((resolve) => setImmediate(resolve));
}
} catch (error) {
failure = { error };
} finally {
try { cursor?.return?.(); } catch (error) { cleanupFailure = { error }; }
try { if (reader?.inTransaction) reader.exec("ROLLBACK"); } catch (error) { cleanupFailure = { error }; }
try { reader?.close(); } catch (error) { cleanupFailure ??= { error }; }
}
if (failure) { rows = []; throw failure.error; }
if (cleanupFailure) { rows = []; throw cleanupFailure.error; }
return rows;
endStretch();
return { rows, windows, longestStretchMs };
}
+35 -11
View File
@@ -54,6 +54,7 @@ import { resolveLegacyTopologyRigsRoot } from "./user-settings/settings-store.js
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
import { validateMissionComposition } from "./lifecycle-manifest.js";
import { cooperativeReadFile, readRowsCooperatively } from "./execution-candidate-reader.js";
import { recordSlowDiagnostic } from "./sync-site-wrap.js";
export const INDETERMINATE = "INDETERMINATE" as const;
export type Indeterminate = typeof INDETERMINATE;
@@ -748,6 +749,7 @@ async function gitAncestor(exec: ExecutionViewDeps["exec"], repoCtx: string, sha
}
export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string; project?: string }): Promise<Record<string, unknown>> {
const projectionStarted = performance.now();
const now = deps.now ?? (() => new Date());
const exec = deps.exec ?? defaultExec;
const buildInfo = deps.buildInfo ?? BUILD_INFO;
@@ -806,23 +808,30 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
// post_claim_motion feeds only derivePickup's legacy fallback, which pending and in-progress rows reach: a blocked row
// returns parked and a closed row terminal before the count is read. Every row and body is still selected here (the
// ladder, EC-3 and wave-map evidence need historical rows); only their unused history count is skipped.
const candidateSql = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
const candidateSelect = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
last_heartbeat, blocked_on, ts_created, ts_updated,
CASE WHEN queue_items.state IN ('pending','in-progress') THEN (SELECT COUNT(*) FROM queue_transitions t
WHERE t.qitem_id = queue_items.qitem_id
AND t.ts > queue_items.claimed_at
AND t.transition_note IS NOT 'claimed') ELSE 0 END AS post_claim_motion
FROM queue_items
WHERE (${missionWhere})`;
FROM queue_items`;
const candidateBinds = missionLikes.flatMap((like) => [like, like]);
// On a WAL database file this full scan runs on its own readonly cursor and yields between slices so unrelated
// work can run; a native open, prepare or step can still block, since the slice budget is soft. It sees committed
// data as of its first step. Inside a transaction, or for a memory or non-WAL database, it stays a synchronous
// read on the main handle (no await on that branch).
// On a WAL database file this full scan runs on its own readonly connection in rowid windows, yielding between
// windows so unrelated work can run even when few rows match; the same filter runs inside each window, so the rows
// are the ones the single statement returns. A native open, a large row or a window's matching rows can still
// block for a while, since the window bound is soft. It sees committed data as of its first read. Inside a
// transaction, or for a memory or non-WAL database, it stays one synchronous read on the main handle (no await on
// that branch).
const cooperativeFile = cooperativeReadFile(deps.db);
const candidateRows = cooperativeFile === null
? deps.db.prepare(candidateSql).all(...candidateBinds) as QueueRowLite[]
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, candidateSql, candidateBinds);
const candidateStarted = performance.now();
const candidateRead = cooperativeFile === null
? null
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, "queue_items",
`${candidateSelect} WHERE queue_items.rowid > ? AND queue_items.rowid <= ? AND (${missionWhere})`, candidateBinds);
const candidateRows = candidateRead
? candidateRead.rows
: deps.db.prepare(`${candidateSelect} WHERE (${missionWhere})`).all(...candidateBinds) as QueueRowLite[];
const candidateMs = performance.now() - candidateStarted;
const rows = candidateRows
.filter(row => mission === INDETERMINATE || rowBindsToMission(row.tags, missionRefs))
@@ -1357,7 +1366,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
df_margin: dfMargin,
};
return {
const view = {
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
view: "execution",
readiness,
@@ -1410,4 +1419,19 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
q5_park: q5,
q6_parallelism: q6,
};
// Measurement only, for a slow projection: its elapsed time, the candidate read's elapsed time, and the candidate
// read's longest synchronous stretch, which a whole-view or awaited span cannot show. The main-handle read is one
// stretch. Counts and times only; no row content.
const projectionMs = performance.now() - projectionStarted;
recordSlowDiagnostic(projectionMs, {
schema: "openrig.execution-view/v1",
utc: new Date().toISOString(),
projectionMs,
candidateMs,
candidateRows: candidateRows.length,
candidateRead: candidateRead ? "windowed" : "main-handle",
candidateWindows: candidateRead?.windows ?? null,
candidateLongestStretchMs: candidateRead?.longestStretchMs ?? candidateMs,
});
return view;
}
+3 -1
View File
@@ -42,7 +42,9 @@ export function freshRecoveryId(db: Database.Database, qitemId: string, overdueD
* idx_queue_items_recovery_tags' WHERE clause (migration 099) byte for byte, which is what lets the
* planner read that small index instead of scanning and parsing every row. */
const RECOVERY_TAG_ROWS = `(tags LIKE '%"recovery-for:%' OR tags LIKE '%\\%')`;
const RECOVERY_ORDER = "ORDER BY CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
// Unary + preserves the numeric CASE order but prevents a prefix match on the full-history listing
// index. Both standalone lookups and the batch need the small recovery-tag index instead.
const RECOVERY_ORDER = "ORDER BY +CASE WHEN state IN ('pending','in-progress','blocked') THEN 0 ELSE 1 END, ts_updated DESC, qitem_id DESC LIMIT 1";
/** A listing's recovery index, built by SQLite's own json_each (the same values, and the same truthy excludeTag
* test, as the per-row query): for each string tag value, the rows that carry it in RECOVERY_ORDER, and each row's
* string values. Built lazily, on the first lookup inside `withRecoveryIndex`, so a listing that never asks (a
+11 -1
View File
@@ -1,4 +1,4 @@
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
import { SLOW_OPERATION_THRESHOLD_MS, type SlowOperationInstrumentation } from "./slow-op-recorder.js";
let recorder: SlowOperationInstrumentation | undefined;
@@ -21,3 +21,13 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
export async function runAsyncSite<T>(site: string, fn: () => Promise<T>): Promise<T> {
return recorder?.runStage ? recorder.runStage(site, fn) : fn();
}
/**
* Offers one diagnostic record to the configured recorder's bounded log, only when `elapsedMs` reached the slow
* threshold, so a hot path adds no record per call. Measurement only: no recorder, a dropped record or a throwing
* recorder changes nothing for the caller.
*/
export function recordSlowDiagnostic(elapsedMs: number, record: Record<string, unknown>): void {
if (!(elapsedMs >= SLOW_OPERATION_THRESHOLD_MS)) return;
try { recorder?.recordDiagnostic?.(record); } catch { /* the recorder reports its own health */ }
}
@@ -361,6 +361,22 @@ export class WorkflowValidator {
});
}
// #3087: validate never resolves seats, so a harness pin is only
// reconciled when a run resolves the step's owner. Say so, so a
// clean result is not read as "every pin is satisfiable".
spec.steps.forEach((step, idx) => {
if (!step.harness) return;
const binding = spec.target?.rig
? `rig "${spec.target.rig}"`
: "no rig is bound";
issues.push({
code: "harness_pin_unchecked",
message: `step "${step.id}" pins harness "${step.harness}", but validate does not check harness pins against any seats (${binding}). The pin is checked when a run resolves the step's owner, and the run fails with harness_pin_unsatisfied if no preferred_target of role "${step.actor_role}" runs ${step.harness}.`,
field: `workflow.steps[${idx}].harness`,
severity: "warning",
});
});
// OPR.0.4.6.WF2 FR-5: gate target semantics — a HUMAN-seat target
// requires summary + evidence_ref (the shipped human-route write
// path enforces them at create; failing HERE is the fail-at-author-
+24 -5
View File
@@ -48,6 +48,23 @@ import { parseSqliteUtcMs } from "../domain/sqlite-time.js";
const generationCensus = new ProcessCensus({ freshnessMs: 0 }); // coalesce concurrent receipts; recheck each later read
const generationThreadIds = new CodexThreadIdResolver({ codexHome: process.env.CODEX_HOME || undefined });
/** The node inventory's synchronous steps run one at a time, each after a turn of the event loop. They're SQLite work
* that can't yield, so concurrent requests ran their whole builds back to back and a read queued behind a burst (a
* /healthz, a queue read) waited for all of them; now it waits for one step. Steps that await I/O run outside. */
let inventoryTurn: Promise<void> = Promise.resolve();
async function inventoryStep<T>(work: () => T): Promise<Awaited<T>> {
const previous = inventoryTurn;
let release = () => {};
inventoryTurn = new Promise<void>((resolve) => { release = () => resolve(); });
try {
await previous;
await new Promise<void>((resolve) => setImmediate(resolve));
return await work();
} finally {
release();
}
}
function terminalAuthGuard(): MiddlewareHandler {
return async (c, next) => {
const token = (c.get("terminalBearerToken" as never) as string | null | undefined) ?? null;
@@ -130,9 +147,9 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
const inventory = contextUsageStore
const inventory = await inventoryStep(() => contextUsageStore
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
: getNodeInventory(deps.rigRepo.db, rigId);
: getNodeInventory(deps.rigRepo.db, rigId));
// Slice 15 — enrich with the two new orthogonal primitives. Order is
// independent (each enrichment reads its own source), so the chain
// composes cleanly with attachAgentActivity. The non-inference
@@ -142,7 +159,8 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
// capture). Opt into the per-node fallback with ?full=true / ?refresh=true — the
// latter already implies a fresh sweep above.
const full = c.req.query("full") === "true" || refresh;
const withActivity = await attachAgentActivity(inventory, {
// The default path reads no pane, so it is one more synchronous step; a full read awaits pane captures, outside.
const attachActivity = () => attachAgentActivity(inventory, {
...inventoryCaptureOptions(deps.rigRepo.db, deps.shadowCapture),
tmuxAdapter: deps.tmuxAdapter,
activityStore: deps.agentActivityStore,
@@ -163,11 +181,12 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
seatActivity: deps.seatActivityService,
captureFallback: full,
});
const withTerminalAndWork = attachTerminalActivityAndWork(withActivity, {
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
db: deps.rigRepo.db,
seatActivity: deps.seatActivityService,
agentActivity: deps.agentActivityStore,
});
}));
// Slice 13 fix 2 — HOST ATTRIBUTION AT THE SOURCE. A daemon only inventories its own seats, so
// every row it serves lives on THIS host; stamping the boot-reconciled self-id here means a
// merged multi-host roster stays attributable row-by-row (a roster missing a host is legible as
@@ -17,6 +17,8 @@ import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
import { EventBus } from "../src/domain/event-bus.js";
import { ViewProjector } from "../src/domain/view-projector.js";
import { cooperativeReadFile, readRowsCooperatively } from "../src/domain/execution-candidate-reader.js";
import { buildExecutionView } from "../src/domain/execution-view.js";
import { configureSyncSiteRecorder, recordSlowDiagnostic } from "../src/domain/sync-site-wrap.js";
const dirs: string[] = [];
const handles: Database.Database[] = [];
@@ -53,42 +55,69 @@ function turns(): { count: () => number; stop: () => void } {
return { count: () => count, stop: () => { stopped = true; } };
}
/** One window of t, the way the execution view binds its windows: exclusive lower and inclusive upper rowid first. */
const WINDOW_SQL = "SELECT id, v FROM t WHERE id > ? AND id <= ? ORDER BY id";
describe("cooperative candidate reads", () => {
it("reads every row in order on its own connection, letting other callbacks run between slices", async () => {
it("reads every row in rowid order on its own connection, a window at a time, letting other callbacks run between windows", async () => {
const { file } = walTable(500);
const loop = turns(), reader = recordingOpen();
const rows = await readRowsCooperatively<{ id: number }>(file, "SELECT id, v FROM t ORDER BY id", [], reader.open);
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, [], reader.open, 64);
loop.stop();
expect(reader.readers.map((db) => [db.readonly, db.open])).toEqual([[true, false]]); // readonly, and closed before returning
expect(rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
expect(reader.readers.map((db) => [db.readonly, db.open, db.inTransaction])).toEqual([[true, false, false]]); // readonly, and closed before returning
expect(read.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
expect(read.windows).toBe(8);
expect(loop.count()).toBeGreaterThan(1);
});
it("keeps one snapshot for the whole read while writers commit between slices; the next read sees them", async () => {
const { file, db } = walTable(500);
// Runs at the reader's first yield, after its cursor has started.
setImmediate(() => db.transaction(() => {
db.prepare("UPDATE t SET v = 'changed' WHERE id = 1").run();
db.prepare("INSERT INTO t(id, v) VALUES (501, 'new')").run();
})());
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
expect(during).toHaveLength(500);
expect(during[0]!.v).toBe(JSON.stringify({ a: 1 }));
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
expect(after).toHaveLength(501);
expect(after[0]!.v).toBe("changed");
it("yields between windows even when the filter matches almost nothing", async () => {
const { file } = walTable(2000);
const loop = turns();
const read = await readRowsCooperatively<{ id: number }>(file, "t", "SELECT id FROM t WHERE id > ? AND id <= ? AND v LIKE ?", ['%"a":2000}%']);
loop.stop();
expect(read.rows).toEqual([{ id: 2000 }]);
expect(read.windows).toBe(8); // 2000 rowids, 256 per window
// A cursor that yields only between returned rows reaches its one match in a single native step, with no turn between.
expect(loop.count()).toBeGreaterThan(1);
});
it("rethrows a native error raised mid-read, returns nothing, and leaves no reader pinning the WAL", async () => {
it("reads an empty table as no rows, and steps over rowid gaps", async () => {
const { file, db } = walTable(0);
expect(await readRowsCooperatively(file, "t", WINDOW_SQL, [])).toMatchObject({ rows: [], windows: 0 });
db.prepare("INSERT INTO t(id, v) VALUES (1, 'a'), (1000, 'b')").run();
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, []);
expect(read.rows.map((r) => r.id)).toEqual([1, 1000]);
expect(read.windows).toBe(4);
});
it("keeps one snapshot for every window while writers commit between windows; the next read sees them", async () => {
const { file, db } = walTable(500);
// Runs at the reader's first yield, after its first window: every change lands in a window it has not read yet.
setImmediate(() => db.transaction(() => {
db.prepare("UPDATE t SET v = 'changed' WHERE id = 500").run();
db.prepare("DELETE FROM t WHERE id = 400").run();
db.prepare("INSERT INTO t(id, v) VALUES (501, 'new')").run();
})());
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, [], undefined, 64);
expect(during.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
expect(during.rows.at(-1)).toEqual({ id: 500, v: JSON.stringify({ a: 500 }) });
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, []);
expect(after.rows).toHaveLength(500);
expect(after.rows.some((r) => r.id === 400)).toBe(false);
expect(after.rows.slice(-2)).toEqual([{ id: 500, v: "changed" }, { id: 501, v: "new" }]);
});
it("rethrows a native error raised in a later window, returns nothing, and leaves no reader pinning the WAL", async () => {
const { file, db } = walTable(500, 300);
const reader = recordingOpen();
const read = readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [], reader.open);
const sql = "SELECT json_extract(v, '$.a') AS a FROM t WHERE id > ? AND id <= ? ORDER BY id";
const read = readRowsCooperatively(file, "t", sql, [], reader.open, 64);
await expect(read).rejects.toMatchObject({ name: "SqliteError", code: "SQLITE_ERROR" });
expect(reader.readers.map((db) => db.open)).toEqual([false]);
db.prepare("INSERT INTO t(id, v) VALUES (501, '{}')").run();
expect(db.pragma("wal_checkpoint(TRUNCATE)")).toEqual([{ busy: 0, log: 0, checkpointed: 0 }]);
db.prepare("DELETE FROM t WHERE id = 300").run();
expect(await readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [])).toHaveLength(500);
expect((await readRowsCooperatively(file, "t", sql, [])).rows).toHaveLength(500);
});
it("stays on the main handle inside a transaction and for memory or non-WAL databases", () => {
@@ -110,12 +139,18 @@ describe("the projector's execution queue", () => {
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
return db;
}
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
const insertRow = (db: Database.Database, id: string, state: string, tags: string[] | null, body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body, claimed_at, last_heartbeat, blocked_on)
VALUES (?, '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', ?, 'normal', 'light', ?, ?, ?, NULL, ?)`)
.run(id, state, JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
.run(id, state, tags && JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
const laneIds = (doc: Record<string, unknown>) => (doc.q1_lanes as Array<{ qitem_id: string }>).map((l) => l.qitem_id).sort();
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
const withoutLiveDisk = (doc: unknown) => {
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
return copy;
};
it("builds one execution at a time in arrival order, each reading fresh, and a failed build does not block the next", async () => {
const db = fixture(tempFile("queue.sqlite"));
@@ -176,14 +211,90 @@ describe("the projector's execution queue", () => {
seed(memory); seed(file);
expect(cooperativeReadFile(memory)).toBeNull();
expect(cooperativeReadFile(file)).not.toBeNull();
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
const withoutLiveDisk = (doc: unknown) => {
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
return copy;
};
const legacy = await documentOf(memory);
expect(withoutLiveDisk(await documentOf(file))).toEqual(withoutLiveDisk(legacy));
expect((legacy as { q1_lanes: unknown[] }).q1_lanes.length).toBeGreaterThan(0);
});
it("binds the same rows through the windowed read when the mission filter matches few of them, edge cases included", async () => {
const missionsRoot = path.dirname(tempFile("missions"));
fs.mkdirSync(path.join(missionsRoot, "release-9"));
fs.writeFileSync(path.join(missionsRoot, "release-9", "SPEC.md"), "---\nid: OPR_9\n---\n\n# Fixture mission\n");
const edges: Array<[id: string, state: string, tags: string[] | null, body: string]> = [
["qitem-tagged", "in-progress", ["mission:release-9", "project:p1", "slice:S1"], "Tagged."],
["qitem-alias-tag", "in-progress", ["mission:OPR_9", "project:p2", "slice:S2"], "Tagged with the mission's SPEC id."],
["qitem-case", "in-progress", ["mission:RELEASE-9", "slice:S3"], "Tagged in another ASCII case."],
["qitem-longer-tag", "in-progress", ["mission:release-90", "slice:S4"], "Another mission whose name contains this one."],
["qitem-other-tag", "in-progress", ["mission:other", "slice:S5"], "Tagged elsewhere; mentions release-9."],
["qitem-body", "in-progress", ["slice:S6", "project:p1"], "Untagged work for release-9."],
["qitem-wildcard", "in-progress", ["slice:S7"], "Untagged; mentions OPRx9, which LIKE's _ matches."],
["qitem-null-tags", "in-progress", null, "Slice: S8\nNo tags; mentions release-9."],
["qitem-empty-body", "in-progress", ["mission:release-9", "slice:S9"], ""],
["qitem-null-tags-empty-body", "in-progress", null, ""],
["qitem-moved", "in-progress", ["mission:release-9", "slice:S10"], "Claimed, then moved three times."],
];
// 700 unrelated rows around the edge rows, so matches are sparse and fall in different windows.
const seed = (db: Database.Database) => db.transaction(() => {
const fill = (from: number, to: number) => {
for (let i = from; i < to; i++) insertRow(db, `qitem-filler-${String(i).padStart(4, "0")}`, "done", ["slice:S1"], `Unrelated work ${i}.`);
};
fill(0, 300);
for (const [id, state, tags, body] of edges.slice(0, 4)) insertRow(db, id, state, tags, body);
fill(300, 600);
for (const [id, state, tags, body] of edges.slice(4)) insertRow(db, id, state, tags, body);
fill(600, 700);
for (let n = 0; n < 3; n++) {
db.prepare("INSERT INTO queue_transitions (qitem_id, ts, state, transition_note, actor_session) VALUES ('qitem-moved', ?, 'in-progress', 'moved', 'builder@r')")
.run(`2026-10-25T11:3${n}:00.000Z`);
}
})();
const memory = fixture(), file = fixture(tempFile("sparse.sqlite"));
seed(memory); seed(file);
expect(cooperativeReadFile(file)).not.toBeNull();
const deps = (db: Database.Database) => ({ db, now: () => NOW, slicesRoot: () => missionsRoot, rigsRoot: () => "/nonexistent-rigs-root",
exec: async (): Promise<string> => { throw new Error("not a repository"); } });
for (const project of [undefined, "p1"]) {
const legacy = await buildExecutionView(deps(memory), { mission: "release-9", project });
expect(withoutLiveDisk(await buildExecutionView(deps(file), { mission: "release-9", project }))).toEqual(withoutLiveDisk(legacy));
expect(laneIds(legacy)).toEqual(project ? ["qitem-body", "qitem-tagged"] : [
"qitem-alias-tag", "qitem-body", "qitem-case", "qitem-empty-body", "qitem-moved", "qitem-null-tags", "qitem-tagged", "qitem-wildcard",
]);
}
});
});
describe("slow execution projections in the slow-operation log", () => {
afterEach(() => configureSyncSiteRecorder(undefined));
it("offers a diagnostic only at the slow threshold, and a throwing recorder changes nothing for the caller", () => {
const offered: unknown[] = [];
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
recordSlowDiagnostic(249.9, { n: 1 });
recordSlowDiagnostic(250, { n: 2 });
recordSlowDiagnostic(Number.NaN, { n: 3 });
expect(offered).toEqual([{ n: 2 }]);
configureSyncSiteRecorder({ recordDiagnostic: () => { throw new Error("recorder broke"); } });
expect(() => recordSlowDiagnostic(300, { n: 4 })).not.toThrow();
});
it("records a slow projection's elapsed times and the candidate read's longest stretch, with no row content", async () => {
const offered: Record<string, unknown>[] = [];
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
const db = track(createDb(tempFile("slow.sqlite")));
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body)
VALUES ('qitem-slow', '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', 'in-progress', 'normal', 'light', ?, ?)`)
.run(JSON.stringify(["slice:S1"]), "Private words.\nworktree_path=/nowhere/slow\n");
// Each Git probe of the row's worktree takes 260 ms, so the projection crosses the 250 ms threshold.
await buildExecutionView({ db, now: () => new Date("2026-10-25T12:00:00.000Z"), slicesRoot: () => null, rigsRoot: () => "/nonexistent-rigs-root",
exec: async (): Promise<string> => { await new Promise((resolve) => setTimeout(resolve, 260)); throw new Error("not a repository"); } });
expect(offered).toHaveLength(1);
const record = offered[0]!;
expect(record).toMatchObject({ schema: "openrig.execution-view/v1", candidateRead: "windowed", candidateRows: 1, candidateWindows: 1 });
expect(record.projectionMs).toBeGreaterThanOrEqual(250);
expect(record.candidateMs).toBeLessThanOrEqual(record.projectionMs as number);
expect(record.candidateLongestStretchMs).toBeLessThanOrEqual(record.candidateMs as number);
expect(Object.keys(record).sort()).toEqual(["candidateLongestStretchMs", "candidateMs", "candidateRead", "candidateRows", "candidateWindows", "projectionMs", "schema", "utc"]);
expect(JSON.stringify(record)).not.toMatch(/Private|nowhere|builder@r|S1/);
});
});
+1
View File
@@ -188,6 +188,7 @@ export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, even
* its exclusion); the guard's redundant-exclusion check then keeps the two in sync.
*/
export const migrationsForFullTestDbExclusions: Record<string, string> = {
"166_queue_list_read_indexes.sql": "read indexes depend on owner-notification columns/archive omitted here (054/076); startup and queue-list-order-indexes.test.ts exercise ALL_MIGRATIONS.",
"156_queue_erasure_link_indexes.sql": "indexes outbox_entries, inbox_entries and the stream tables (all excluded from the core edge) for queue erasure's lookups; erasure tests migrate the canonical full schema.",
"082_archive_identity_provenance.sql": "archive extension; 054 is excluded from this core fixture. Archive identity regressions use ALL_MIGRATIONS.",
"026_inbox_entries.sql": "inbox subsystem table — not on the shared core edge (inbox suites migrate it inline).",
@@ -23,6 +23,10 @@ import { HEALTHZ_RESPONSIVENESS_BUDGET_MS } from "../src/domain/event-loop-monit
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, "../../..");
const CHILD = path.resolve(HERE, "fixtures/ps-stall-http-child.ts");
// The nodes wave repeats until its probes have this many /healthz samples (the ps+summary burst takes about 20),
// so its worst probe is a measurement, not one tick; the cap keeps it inside the run's watchdog.
const NODES_MIN_PROBES = 20;
const NODES_BURST_CAP_MS = 20_000;
// exact SQL signatures (normalized, lowercased) of the two per-node fold scans
const STARTUP_SIG = "type in ('node.startup_challenged','node.startup_proof_skipped','node.startup_proof_verified','node.startup_proof_rejected')";
@@ -256,26 +260,39 @@ describe("slice-04 ps/summary event-loop regression", () => {
})();
if (!nodes) probe = startProbe();
const reqs: Promise<unknown>[] = [];
for (let i = 0; i < 24; i++) {
if (nodes) {
const rig = `rig-act-${String(i % ACTIVE_RIGS).padStart(2, "0")}`;
reqs.push(fetch(`${base}/api/rigs/${rig}/nodes`).then(async (r) => {
psStatuses.push(r.status);
const rows = await r.json();
nodeCounts.push(Array.isArray(rows) && rows.every((n: { rigId: string }) => n.rigId === rig) ? rows.length : 0);
}));
continue;
if (nodes) {
// One wave of 24 nodes reads ends before a second probe can start, so one sample decided the budget. The same
// wave is sent again, each one probed only while it runs, until the probes number NODES_MIN_PROBES (or the cap).
const burstUntil = performance.now() + NODES_BURST_CAP_MS;
do {
const reqs: Promise<unknown>[] = [];
for (let i = 0; i < 24; i++) {
const rig = `rig-act-${String(i % ACTIVE_RIGS).padStart(2, "0")}`;
reqs.push(fetch(`${base}/api/rigs/${rig}/nodes`).then(async (r) => {
psStatuses.push(r.status);
const rows = await r.json();
nodeCounts.push(Array.isArray(rows) && rows.every((n: { rigId: string }) => n.rigId === rig) ? rows.length : 0);
}));
}
// Queue the competing reads after the wave's requests, not ahead of all of them.
stop = false;
probe = startProbe();
await Promise.all(reqs);
stop = true;
await probe;
probe = null;
} while (health.length < NODES_MIN_PROBES && performance.now() < burstUntil);
} else {
const reqs: Promise<unknown>[] = [];
for (let i = 0; i < 24; i++) {
reqs.push(fetch(base + "/api/ps").then((r) => { psStatuses.push(r.status); return r.text(); }));
reqs.push(fetch(base + "/api/rigs/summary").then((r) => { sumStatuses.push(r.status); return r.text(); }));
}
reqs.push(fetch(base + "/api/ps").then((r) => { psStatuses.push(r.status); return r.text(); }));
reqs.push(fetch(base + "/api/rigs/summary").then((r) => { sumStatuses.push(r.status); return r.text(); }));
await Promise.all(reqs);
stop = true;
await probe;
probe = null;
}
// For nodes, queue the competing reads after the burst requests, not ahead of all of them.
if (nodes) probe = startProbe();
await Promise.all(reqs);
stop = true;
await probe;
probe = null;
} finally {
// Stop + ABORT the probe first so its in-flight fetch cannot stall the drain.
stop = true;
@@ -306,7 +323,7 @@ describe("slice-04 ps/summary event-loop regression", () => {
? `${Math.max(...health.map((h) => h.ms))} ms over ${health.length} probes`
: "no probes completed";
console.log(`[${nodes ? "nodes" : "ps-summary"} D2] worst /healthz ${measured} (budget ${HEALTHZ_RESPONSIVENESS_BUDGET_MS} ms; `
+ `${nodes ? "nodes" : "ps"} 200s ${psStatuses.filter((st) => st === 200).length}/24`
+ `${nodes ? "nodes" : "ps"} 200s ${psStatuses.filter((st) => st === 200).length}/${nodes ? psStatuses.length : 24}`
+ (nodes ? ")" : `, summary 200s ${sumStatuses.filter((st) => st === 200).length}/24)`));
if (nodes) {
const stats = (samples: typeof health) => ({ count: samples.length, worstMs: Math.max(0, ...samples.map(s => s.ms)), statuses: samples.map(s => s.status) });
@@ -315,10 +332,13 @@ describe("slice-04 ps/summary event-loop regression", () => {
}
// Teardown is guaranteed done above; now assert (the budget pin is the LAST assertion).
expect(psStatuses).toHaveLength(24);
if (nodes) expect(psStatuses.length).toBeGreaterThanOrEqual(24);
else expect(psStatuses).toHaveLength(24);
expect(psStatuses.every((s) => s === 200)).toBe(true);
if (nodes) {
expect(nodeCounts).toHaveLength(24);
expect(nodeCounts).toHaveLength(psStatuses.length);
// Fewer means the burst ended (or hit its cap) before enough probes ran, and the worst below is too few samples.
expect(health.length, `the nodes burst took only ${health.length} /healthz probes`).toBeGreaterThanOrEqual(NODES_MIN_PROBES);
expect(nodeCounts.every(n => n > 0)).toBe(true);
expect(baselineHealth).toHaveLength(8);
expect(baselineQueue).toHaveLength(8);
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Database from "better-sqlite3";
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, type QueueListOptions } from "../src/domain/queue-repository.js";
import { RECOVERY_INDEX_SQL, findQueueRecovery } from "../src/domain/queue-recovery.js";
import { QueueTransitionLog } from "../src/domain/queue-transition-log.js";
import { runStuckSweep } from "../src/domain/queue-stuck-sweep.js";
import { OutboxHandler } from "../src/domain/outbox-handler.js";
import { RigRepository } from "../src/domain/rig-repository.js";
describe("bounded queue lists use their ordering indexes", () => {
let db: Database.Database;
let repo: QueueRepository;
beforeEach(() => {
db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => ({ ok: true, entities: [] }) });
const insert = db.prepare(`INSERT INTO queue_items
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, tags)
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, ?, ?)`);
db.transaction(() => {
for (let i = 0; i < 240; i++) {
// Equal timestamps across states exercise the existing scan's tie order too.
const ts = new Date(Date.UTC(2026, 10, 12, 0, 0, Math.floor(i / 3))).toISOString();
const tags = i % 7 === 0 ? ["first", "second"] : i % 2 ? ["first"] : ["second"];
insert.run(`q-${i}`, ts, ts, ["pending", "done", "blocked"][i % 3], "body".repeat(256), JSON.stringify(tags));
}
})();
});
afterEach(() => { vi.restoreAllMocks(); db.close(); });
function selection(options: QueueListOptions) {
const prepare = db.prepare.bind(db);
let selected: { sql: string; args: unknown[] } | undefined;
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
const statement = prepare(sql);
if (/^SELECT (?:\*|qitem_id,).* FROM queue_items\s+(?:WHERE\b|ORDER BY\b)/s.test(sql)) {
const all = statement.all.bind(statement);
statement.all = (...args: unknown[]) => { selected = { sql, args }; return all(...args); };
}
return statement;
});
let ids: string[];
try { ids = repo.list(options).map((item) => item.qitemId); }
finally { spy.mockRestore(); }
expect(selected).toBeDefined();
const { sql, args } = selected!;
const legacy = prepare(sql.replace("FROM queue_items", "FROM queue_items NOT INDEXED"))
.all(...args) as Array<{ qitem_id: string }>;
expect(ids).toEqual(legacy.map((row) => row.qitem_id));
const plan = prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...args) as Array<{ detail: string }>;
return { ids, details: plan.map((row) => row.detail).join("\n") };
}
it.each([false, true])("does not scan and sort all rows for a bounded list (compact=%s)", (compact) => {
const { ids, details } = selection({ compact, limit: 6 });
expect(ids).toHaveLength(6);
expect(details).toContain("USING INDEX");
expect(details).not.toContain("TEMP B-TREE");
});
it.each([false, true])("keeps active rows first without sorting all rig history (compact=%s)", (compact) => {
const { ids, details } = selection({ rig: "r", compact, limit: 6 });
expect(ids).toHaveLength(6);
expect(details).toContain("USING INDEX");
expect(details).not.toContain("TEMP B-TREE");
});
it.each([false, true])("keeps scoped update-time ordering indexed (compact=%s)", (compact) => {
const { ids, details } = selection({ rig: "r", sortByUpdated: true, compact, limit: 6 });
expect(ids).toHaveLength(6);
expect(details).toContain("USING INDEX");
expect(details).not.toContain("TEMP B-TREE");
});
it("looks for owner notifications without reading ordinary live or archived transitions", () => {
const log = new QueueTransitionLog(db);
const prepare = db.prepare.bind(db);
let query = "";
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
if (sql.includes("ROW_NUMBER()") && sql.includes("owner_notification_level IS NOT NULL")) query = sql;
return prepare(sql);
});
try { expect(log.ownerNotificationsForQitems(["q-0"])).toEqual(new Map()); }
finally { spy.mockRestore(); }
expect(query).not.toBe("");
const plan = prepare(`EXPLAIN QUERY PLAN ${query}`).all(JSON.stringify(["q-0"])) as Array<{ detail: string }>;
const details = plan.map((row) => row.detail).join("\n");
expect(details).toContain("idx_queue_transitions_owner_notification_qitem");
expect(details).toContain("idx_queue_transitions_archive_owner_notification_qitem");
});
it("still applies both exact tags before the bound, including zero and unbounded limits", () => {
for (const limit of [0, 1, 6, -1]) {
const { ids } = selection({ rig: "r", tag: ["first", "second"], limit });
expect(ids.every((id) => Number(id.slice(2)) % 7 === 0)).toBe(true);
expect(ids).toHaveLength(limit < 0 ? 35 : Math.min(limit, 35));
}
});
it("keeps recovery lookup on its small partial index when listing indexes exist", () => {
const plan = db.prepare(`EXPLAIN QUERY PLAN ${RECOVERY_INDEX_SQL}`).all() as Array<{ detail: string }>;
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
});
async function pointPlan(prefix: string, run: () => unknown | Promise<unknown>) {
const prepare = db.prepare.bind(db);
let selected: { sql: string; args: unknown[] } | undefined;
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
const statement = prepare(sql);
if (sql.startsWith(prefix)) {
const get = statement.get.bind(statement);
statement.get = (...args: unknown[]) => { selected = { sql, args }; return get(...args); };
}
return statement;
});
try { await run(); } finally { spy.mockRestore(); }
expect(selected).toBeDefined();
const plan = prepare(`EXPLAIN QUERY PLAN ${selected!.sql}`).all(...selected!.args) as Array<{ detail: string }>;
expect(plan.some((row) => row.detail.includes("idx_queue_items_recovery_tags"))).toBe(true);
}
it("keeps standalone recovery hits and misses on the recovery partial index", async () => {
db.prepare("UPDATE queue_items SET tags = ? WHERE qitem_id = 'q-0'").run(JSON.stringify(["recovery-for:subject"]));
for (const subject of ["subject", "no-match"]) {
for (const exclude of [undefined, "excluded"]) {
await pointPlan("SELECT qitem_id, state, ts_updated FROM queue_items", () => {
expect(findQueueRecovery(db, subject, exclude)?.qitemId ?? null).toBe(subject === "subject" ? "q-0" : null);
});
}
}
});
async function sweepForPlan(state: "pending" | "in-progress") {
db.exec("DELETE FROM queue_items");
repo.attachOutbox(new OutboxHandler(db));
if (state === "pending") {
const rigs = new RigRepository(db);
const rig = rigs.createRig("r");
const node = rigs.addNode(rig.id, "worker", { runtime: "claude-code" });
db.prepare("INSERT INTO sessions (id,node_id,session_name,status) VALUES ('plan-session',?,'worker@r','running')").run(node.id);
}
const past = new Date(Date.now() - 120 * 60_000).toISOString();
const insert = db.prepare(`INSERT INTO queue_items
(qitem_id, ts_created, ts_updated, source_session, destination_session, state, body, claimed_at, closure_required_at)
VALUES (?, ?, ?, 'sender@r', 'worker@r', ?, 'work', ?, ?)`);
for (let i = 0; i < (state === "pending" ? 3 : 1); i++) {
insert.run(`candidate-${i}`, past, past, state, state === "in-progress" ? past : null, state === "in-progress" ? past : null);
}
return runStuckSweep({ db, queueRepo: repo, resolveOrchestrator: () => null, resolveRecoveryAgentSeat: () => null,
resolveOperatorSeat: () => null, isRegisteredHost: () => false, unclaimedAgeMinutes: 60, log: () => {} });
}
it("keeps the per-row finding lookup on the recovery partial index", async () => {
await pointPlan("SELECT qitem_id, source_session, state, ts_updated FROM queue_items", () => sweepForPlan("in-progress"));
});
it("keeps the latest backlog finding lookup on the recovery partial index", async () => {
await pointPlan("SELECT qitem_id, state, ts_updated, summary, body, destination_session, tags FROM queue_items", () => sweepForPlan("pending"));
});
});
@@ -96,6 +96,8 @@ describe("Slack sweep selection", () => {
it("keeps a legacy schema without owner classification quiet", async () => {
row("legacy");
for (const table of ["queue_transitions", "queue_transitions_archive"]) {
// The simulated pre-classification schema also predates its partial index.
db.exec(`DROP INDEX idx_${table}_owner_notification_qitem`);
db.exec(`ALTER TABLE ${table} DROP COLUMN owner_notification_level; ALTER TABLE ${table} DROP COLUMN owner_notification_kind`);
}
repo = new QueueRepository(db, new EventBus(db), { loadHumanRegistry: () => registry });
@@ -577,6 +577,23 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
).rejects.toMatchObject({ code: "harness_pin_unsatisfied" });
});
it("validate warns that a harness pin is unchecked instead of reporting a clean result (#3087)", () => {
const spec = parseWorkflowSpec(HARNESS_SPEC, "harness.yaml");
const result = new WorkflowValidator().validate(spec);
expect(result.ok).toBe(true);
const unchecked = result.issues.filter((i) => i.code === "harness_pin_unchecked");
expect(unchecked).toHaveLength(1);
expect(unchecked[0]).toMatchObject({ severity: "warning", field: "workflow.steps[0].harness" });
expect(unchecked[0]!.message).toContain('"codex"');
expect(unchecked[0]!.message).toContain("no rig is bound");
});
it("validate adds no pin warning to a spec without harness pins", () => {
const spec = parseWorkflowSpec(LINEAR_SPEC, "linear.yaml");
const result = new WorkflowValidator().validate(spec);
expect(result.issues.some((i) => i.code === "harness_pin_unchecked")).toBe(false);
});
it("steps with no pin resolve preferred_targets[0] unchanged (zero regression)", async () => {
const specPath = writeSpec(tmp, "linear.yaml", LINEAR_SPEC);
const result = await runtime.instantiate({