Compare commits

..
Author SHA1 Message Date
dev-driver 6604c72a94 docs(roadmap): name 0.7.13 as the current release 2026-11-13 00:53:22 +00:00
22 changed files with 113 additions and 947 deletions

No files matched your search

+3 -3
View File
@@ -24,9 +24,9 @@ committed change. Two repairs aren't in this release: see Known limits.
### Before you upgrade
- **Back up the database, install 0.7.13, then restart the daemon:** `rig daemon stop`,
copy the database file (`rig config get db.path` prints its path), install
OpenRig 0.7.13 using your existing installation method, then `rig daemon start`. Your agents keep running through a daemon restart; don't
- **Back up the database, then restart the daemon:** `rig daemon stop`, copy the
database file (`rig config get db.path` prints its path), then
`rig daemon start`. Your agents keep running through a daemon restart; don't
use `rig down`, which ends them. Three migrations run on the daemon's first start: 160 and 161 (for the experimental hub connector below)
and 162 (indexes for session reads). Going back to 0.7.12 after them isn't
established. ([#2993](https://github.com/mvschwarz/openrig/pull/2993), [#2997](https://github.com/mvschwarz/openrig/pull/2997))
+2 -2
View File
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.12
## Released in 0.7.13
[0.7.12 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.12): OpenRig 0.7.12 is a patch for people running their own agent teams. When you answer your team, by typing, clicking or qualifying an answer, the answer now goes to an agent that reads it with the question and decides. When no receiving agent is available, your answer waits. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.13 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.13): OpenRig 0.7.13 is a patch for people running their own agent teams. When you answer a runner's permission request, you now get a note back saying who decides it, and the runner wakes on the decision. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
-7
View File
@@ -177,13 +177,6 @@ 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.
+3 -7
View File
@@ -60,13 +60,9 @@ 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, 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.
- 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.
- 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:
+9 -53
View File
@@ -1,5 +1,5 @@
import { Command } from "commander";
import { ConfigStore, ENV_MAP, VALID_KEYS, type ResolvedSetting, type ValidKey } from "../config-store.js";
import { ConfigStore, 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,40 +10,6 @@ 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"];
@@ -254,16 +220,11 @@ Precedence: CLI flag > environment variable > config file > default`)
try {
store.set(key, value);
const resolved = store.resolveWithSource(key);
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}`);
}
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"
@@ -285,14 +246,9 @@ Precedence: CLI flag > environment variable > config file > default`)
store.reset(key);
if (key) {
const resolved = store.resolveWithSource(key);
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}).`);
}
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 {
+5 -38
View File
@@ -1319,12 +1319,6 @@ 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;
@@ -1591,34 +1585,6 @@ 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];
@@ -1632,12 +1598,13 @@ 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 state = this.resolveContextPressureStateRaw(fileConfig, workspaceRoot);
if (!state.rejectedPair) {
return { [warningKey]: state.warning, [criticalKey]: state.critical };
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 };
}
process.stderr.write(
`[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`,
`[openrig-config] context-pressure policy rejected: warning (${warning.value}) must be less than critical (${critical.value}); falling back to 95/99 defaults\n`,
);
return {
[warningKey]: { value: 95, source: "default", defaultValue: 95 },
+40 -103
View File
@@ -503,28 +503,6 @@ 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
@@ -684,41 +662,25 @@ 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;
}
/** 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[]> {
/** 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]!;
const rig = await resolveRigHandle(client, rigName, { strict: true });
if (rig.kind !== "resolved") throw new Error(`rig ${rigName} isn't one active rig here`);
if (rig.kind !== "resolved") return false;
const nodes = await client.get(`/api/rigs/${encodeURIComponent(rig.id)}/nodes`);
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>()];
if (nodes.status !== 200 || !Array.isArray(nodes.data)) return false;
let matches = 0;
for (const entry of nodes.data as unknown[]) {
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 { logicalId, rigName: name } = (entry ?? {}) as Record<string, unknown>;
if (typeof logicalId !== "string" || name !== rigName) return false;
const dot = logicalId.indexOf(".");
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);
if (dot <= 0 || dot === logicalId.length - 1) return false;
if (`${logicalId.slice(0, dot)}-${logicalId.slice(dot + 1)}@${name}` === destination) matches++;
}
return [...(byOwn.size > 0 ? byOwn : byPodMember.size > 0 ? byPodMember : bySlug)];
return matches === 1;
}
/** 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.
@@ -842,15 +804,6 @@ 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. */
@@ -1398,10 +1351,10 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_create")) server.registerTool(
"rig_queue_create",
{
...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),
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.",
inputSchema: z.object({
destination: access?.external
? 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().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().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"),
@@ -1412,6 +1365,7 @@ 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
@@ -1427,46 +1381,22 @@ 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 {
seats = await namedSeats(client, destination.slice(at + 1), destination.slice(0, at), (seat) => scope?.destination(seat) === true);
if (!await taskDestinationExists(client, destination)) return unavailable();
} catch { return unavailable(); }
if (seats.length !== 1) return unavailable();
destination = seats[0]!;
} else if (destination !== undefined && access?.external && !destination.includes("@")) {
// 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.
// 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.
const named = destination;
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);
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 */ }
}
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 (!exists) return outOfScope(`Destination ${named}`, omitHint);
destination = resolved!;
}
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.
@@ -1533,10 +1463,11 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_show")) server.registerTool(
"rig_queue_show",
{
...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),
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.",
inputSchema: z.object({
qitemId: z.string().describe("The qitemId returned by rig_queue_create (or any queue item)"),
}),
annotations: READ_ONLY,
},
async ({ qitemId }) => {
try {
@@ -1566,7 +1497,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_list")) server.registerTool(
"rig_queue_list",
{
...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),
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.",
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"),
@@ -1581,6 +1512,7 @@ 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 {
@@ -1644,8 +1576,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_claim")) server.registerTool(
"rig_queue_claim",
{
...worded("rig_queue_claim", "Claim a queue item assigned to this seat before working on it.", DESTRUCTIVE),
description: "Claim a queue item assigned to this seat before working on it.",
inputSchema: z.object({ qitemId: z.string().describe("The queue item to claim") }),
annotations: DESTRUCTIVE,
},
async ({ qitemId }) => {
try {
@@ -1665,7 +1598,7 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_queue_update")) server.registerTool(
"rig_queue_update",
{
...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),
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.",
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)"),
@@ -1679,6 +1612,7 @@ 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 {
@@ -1765,8 +1699,9 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_whoami")) server.registerTool(
"rig_whoami",
{
...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),
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).",
inputSchema: z.object({}),
annotations: READ_ONLY,
},
async () => {
try {
@@ -1821,10 +1756,11 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_team")) server.registerTool(
"rig_team",
{
...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),
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.",
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 {
@@ -1997,13 +1933,14 @@ export function createMcpServer(client: DaemonClient, options: { seat?: string;
if (offered("rig_artifact_read")) server.registerTool(
"rig_artifact_read",
{
...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),
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.`,
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 {
@@ -1,183 +0,0 @@
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: "lead@team-a", body: args.body });
expect(repo.getById(args.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-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: "lead@team-a",
expect(repo.getById(revision.structured.qitemId)).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-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: "lead@team-a" });
expect(repo.getById("qitem-modern-first")).toMatchObject({ sourceSession: CALLER, destinationSession: "pod-lead@team-a" });
});
it("does not infer a receiver from grant metadata, caller identity or an empty queue", async () => {
@@ -219,129 +219,16 @@ 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: "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(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((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.
@@ -349,7 +236,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: "lead@team-a" });
expect(repo.getById("qitem-bare-receiver")).toMatchObject({ destinationSession: "pod-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.") });
@@ -364,9 +251,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: "lead@team-a" });
expect(repo.getById("qitem-own-lead")).toMatchObject({ destinationSession: "pod-lead@team-a" });
const refused = await ask("rig_queue_create", { destination: "my-lead", body: "Nobody" });
expect(refused.structured.code).toBe("destination_not_found");
expect(refused.structured.code).toBe("out_of_scope");
expect(refused.structured.message).not.toContain("Omit destination");
expect(rows()).toBe(1);
});
@@ -96,8 +96,7 @@ 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());
// A title is a display name, not a hint.
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, { ...tool.annotations, title: undefined }]));
const hints = Object.fromEntries([...tools, ...personTools].map((tool) => [tool.name, tool.annotations]));
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.
+2 -51
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, PLAIN_WORDING, 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, type McpAccess, type UnfilledSeat } from "../src/mcp-server.js";
import { DaemonClient, DaemonConnectionError, senderIdentityHeaders, type DaemonResponse } from "../src/client.js";
function mockClient(overrides?: {
@@ -246,8 +246,7 @@ 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"];
// 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 }]));
const hints = Object.fromEntries(tools.map((tool) => [tool.name, tool.annotations]));
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 });
@@ -882,54 +881,6 @@ 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,
+4 -27
View File
@@ -494,29 +494,7 @@ export class TmuxAdapter {
if (probe.state !== "present") return false;
const panes = await this.listPanes(target.session);
return panes.length === 1 && panes[0]!.id === target.pane && await this.isPaneDead(target.pane);
}, target => this.lastClientActivity(target.session));
}
/** When a writable tmux client attached to this session last had activity (epoch ms, whole seconds), or null when no
* writable client is attached. tmux's client_activity is session-wide and covers more than text keys, so this is
* recent writable-client activity, not proof of typing in the pane. A read-only client (a viewer tile) can't type
* into the seat and doesn't count. An attach counts: tmux can't tell a key in the attach's second from the attach,
* so a delivery can be held briefly after one. A tmux failure or an unreadable row throws: the guard then holds
* the paste rather than take it for "no activity" (#3076). */
async lastClientActivity(session: string): Promise<number | null> {
const exact = exactTarget(session, "session");
const format = `#{client_readonly}${TMUX_FIELD_SEPARATOR}#{client_activity}`;
const output = await this.run(["tmux", "list-clients", "-t", exact, "-F", format],
`tmux list-clients -t ${shellQuote(exact)} -F "${format}"`);
let latest: number | null = null;
for (const line of output.split("\n").map(row => row.trim()).filter(row => row !== "")) {
const [readonly, activity, ...rest] = line.split(TMUX_FIELD_SEPARATOR);
if (rest.length || !/^[01]$/.test(readonly ?? "") || !/^[1-9]\d*$/.test(activity ?? "")) {
throw new Error(`tmux list-clients: unreadable client row for ${session}`);
}
if (readonly === "0") latest = Math.max(latest ?? 0, Number(activity) * 1000);
}
return latest;
});
}
private readonly freshProbes = new Map<string, string>();
private readonly freshManaged = new Map<string, {nodeId: string; pane: string}>();
@@ -1103,13 +1081,12 @@ export class TmuxAdapter {
// A paste the guard proves it left unsubmitted (#2429) is submitted first, under this lease, once this send's own
// checks pass; they then aren't run a second time.
beforePaste: async () => {
// This send's own checks can wait (a conversation lookup, an input capture), so they run first, and the
// guard's checks run last (#3076). A check that refuses leaves a leftover's record for the next delivery.
await options?.beforePaste?.();
const checks = async () => { beforeWrite(); beforeInput?.(); };
let checked = false;
const checks = async () => { await options?.beforePaste?.(); beforeWrite(); beforeInput?.(); checked = true; };
// The synchronous checks run again just before the key: the guard's re-read awaits after `checks`.
const enter = async () => { beforeWrite(); beforeInput?.(); return (await this.sendKeysUnchecked(pane, ["Enter"])).ok; };
if (checkDraft) await guard!.beforeInput(identity, false, text, { checks, enter });
if (!checked) await options?.beforePaste?.();
},
afterPaste: async () => { if (checkDraft) await guard!.notePasted(identity, text); },
} : undefined;
@@ -60,15 +60,6 @@ export interface TypingGuardConfig extends TypingGuardSettings { revision: strin
const DEFAULT_GUARD: TypingGuardConfig = { mode: "off", revision: "legacy-off" };
/** How long the guard waits before reading again an input it couldn't read right after its own paste or Enter (#2429). */
const OWN_INPUT_REREAD_MS = 300;
/** Right before an automatic paste, the empty input is read once more after this wait: a draft typed (or redrawn by the
* agent's TUI) since the first read is seen before the paste, not after it, where nothing is removed (#2447, #3076). */
const PASTE_SETTLE_MS = 300;
/** Recent activity at a writable tmux client of the seat (keys, but also other client input; session-wide, not proof of
* typing in this pane) holds the paste: a later attempt can deliver once it reads quiet and the input empty. tmux
* reports client activity in whole seconds, so this is three of them (#3076). */
const INPUT_QUIET_MS = 3000;
/** tmux's client_activity is in whole seconds; the activity may have come up to this much after the second it reports. */
const CLIENT_ACTIVITY_PRECISION_MS = 999;
/** A terminal member with nothing set holds OpenRig's own notices and takes deliberate sends. */
const TERMINAL_DEFAULT: TypingGuardConfig = { mode: "off", level: "sends-only", revision: "terminal-default" };
/** The actor a guard change records when its request carries no seat identity, as from an ordinary terminal (#2350).
@@ -131,9 +122,6 @@ export class SeatDeliveryGuard {
/** A paste this guard kept from being submitted because it couldn't read the input, per node (#2429). */
private readonly leftovers = new Map<string, { target: GuardTarget; humanEpoch: number; text: string; collapsedPaste: string | null }>();
private inspectInput?: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>;
/** When a writable tmux client of the seat last had activity (epoch ms, whole seconds); null when no writable client
* is attached; a rejection when that can't be known. */
private inputActivity?: (target: GuardTarget) => Promise<number | null>;
private inputAbsent?: (target: GuardTarget) => Promise<boolean>;
private readonly hasModeConfig: boolean;
@@ -145,11 +133,9 @@ export class SeatDeliveryGuard {
this.hasModeConfig = (db.prepare("PRAGMA table_info(seat_delivery_guards)").all() as Array<{ name: string }>).some(c => c.name === "desired_config");
}
attachInputInspection(inspect: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>, absent: (target: GuardTarget) => Promise<boolean>,
activity?: (target: GuardTarget) => Promise<number | null>): void {
attachInputInspection(inspect: (target: GuardTarget, owned?: ComposerOwnership) => Promise<ComposerInput | null>, absent: (target: GuardTarget) => Promise<boolean>): void {
this.inspectInput = inspect;
this.inputAbsent = absent;
this.inputActivity = activity;
}
/** Startup-only, before exposing routes or starting writers. A stopped operation cannot
@@ -497,9 +483,6 @@ export class SeatDeliveryGuard {
} else if (input.state !== "empty") {
throw new DeliveryGuardError("draft_input_busy", "The seat has an unsubmitted input. Automatic delivery is held.");
} else {
// Right before the paste (the adapter's check passes the text): the read above is a snapshot, and a draft typed
// after it would get the paste appended and then lose the Enter, with the paste left in the person's input (#3076).
if (pasteText !== undefined) input = await this.quietBeforePaste(target, read, unknown);
const uncertainty = pasteText === undefined ? null : composerPasteUncertainty(input, pasteText);
if (uncertainty) {
const message = uncertainty === "controls"
@@ -513,31 +496,6 @@ export class SeatDeliveryGuard {
}
}
/** No recent writable-client activity before a short wait, the input still empty when read again after it, and still
* no recent activity after that read. A time tmux can't report holds the paste, as an unreadable input does. The
* read it returns is the one the paste goes on. Typing after this final check and before the paste can still leave
* the notice beside the person's draft. In the tested append-mode schedule the Enter is then refused and nothing is
* submitted or deleted; that isn't a guarantee for every interleaving (#3076). */
private async quietBeforePaste(target: GuardTarget, read: () => Promise<ComposerInput | null>,
unknown: (reading: ComposerInput | null) => DeliveryGuardError): Promise<ComposerInput> {
const quiet = async () => {
if (!this.inputActivity) return;
const last = await this.inputActivity(target).then(at => ({ at }), () => null);
if (!last) throw new DeliveryGuardError("draft_input_unknown", "OpenRig couldn't read the recent activity of the seat's terminal clients. Automatic delivery is held; nothing was pasted.");
// tmux reports whole seconds: activity at N.999 s reads as N, so the quiet window counts from the end of that second.
if (last.at !== null && Date.now() - (last.at + CLIENT_ACTIVITY_PRECISION_MS) < INPUT_QUIET_MS) {
throw new DeliveryGuardError("draft_input_busy", "A writable client of this seat had recent activity. Automatic delivery is held; nothing was pasted.");
}
};
await quiet();
await this.sleep(PASTE_SETTLE_MS);
const again = await read();
if (!again || again.state === "unknown") throw unknown(again);
if (again.state !== "empty") throw new DeliveryGuardError("draft_input_busy", "The input changed just before the paste. Automatic delivery is held; nothing was pasted.");
await quiet();
return again;
}
/** A leftover paste is OpenRig's only while it's exactly there: the same occupant, no person's input through OpenRig
* since, and its text or the label recorded for it. A label first seen later proves nothing (#1871). */
private ownsLeftover(target: GuardTarget, input: ComposerInput): boolean {
+5 -24
View File
@@ -48,23 +48,6 @@ 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;
@@ -147,9 +130,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 = await inventoryStep(() => contextUsageStore
const inventory = 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
@@ -159,8 +142,7 @@ 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;
// The default path reads no pane, so it is one more synchronous step; a full read awaits pane captures, outside.
const attachActivity = () => attachAgentActivity(inventory, {
const withActivity = await attachAgentActivity(inventory, {
...inventoryCaptureOptions(deps.rigRepo.db, deps.shadowCapture),
tmuxAdapter: deps.tmuxAdapter,
activityStore: deps.agentActivityStore,
@@ -181,12 +163,11 @@ nodesRoutes.get("/", observeInventoryHandler, async (c) => {
seatActivity: deps.seatActivityService,
captureFallback: full,
});
const withActivity = full ? await attachActivity() : await inventoryStep(attachActivity);
const withTerminalAndWork = await inventoryStep(() => attachTerminalActivityAndWork(withActivity, {
const withTerminalAndWork = 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
@@ -32,7 +32,7 @@ function signal() {
return { ready: new Promise<void>(resolve => { release = resolve; }), release: () => release() };
}
function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean; appendPaste?: boolean } = {}) {
function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean } = {}) {
const db = new Database(saved ?? ":memory:");
if (!saved) {
migrate(db, ALL_MIGRATIONS);
@@ -42,27 +42,23 @@ function fixture(saved?: Buffer, opts: { runtime?: string; idleHook?: boolean; a
INSERT INTO sessions(id,node_id,session_name,status) VALUES ('sa','a','worker@test','running'),('sb','b','sibling@test','running');
INSERT INTO occupant_tenures(id,node_id,generation_ordinal,generation_uuid,kind) VALUES ('ga','a',1,'g1','fresh'),('gb','b',1,'g2','fresh');`);
}
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void>;
settle?: (ms: number) => void | Promise<void>; clients?: () => string } = {};
// The guard's own waits (its re-reads) go to `settle`, kept apart from the transport's `sleep`; none is real time.
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name), async ms => { await hooks.settle?.(ms); });
const guard = new SeatDeliveryGuard(db, name => resolveGuardTarget(db, name));
guard.recoverActivation();
const bodies: Record<string, string | null> = { a: "", b: "" };
const history: Record<string, string> = { a: "", b: "" };
const writes: string[] = [], submissions: string[] = [];
const observations: unknown[] = [];
const hooks: { load?: () => void | Promise<void>; paste?: () => void | Promise<void>; sleep?: (ms: number) => void | Promise<void> } = {};
let staged = "";
// A terminal pane shows a submitted line as typed; an agent pane echoes a sent message under its prompt, as Claude does.
const echo = (node: string, body: string) => (db.prepare("SELECT runtime FROM nodes WHERE id=?").get(node) as { runtime: string }).runtime === "terminal"
? `${body}\n` : `${body.split("\n").map((row, i) => (i ? " " : "❯ ") + row).join("\n")}\n`;
const tmux = new TmuxAdapter(async command => {
if (command.includes("list-clients")) return hooks.clients?.() ?? "";
if (command.includes("load-buffer")) await hooks.load?.();
const node = command.includes("%2") ? "b" : "a";
if (command.includes("paste-buffer")) {
await hooks.paste?.();
// appendPaste: a paste lands after whatever the input already holds, as in a real composer (#3076 tests).
writes.push(`paste:${node}:${staged}`); bodies[node] = opts.appendPaste ? (bodies[node] ?? "") + staged : staged;
writes.push(`paste:${node}:${staged}`); bodies[node] = staged;
}
if (command.includes("send-keys")) {
writes.push(`keys:${node}:${command}`);
@@ -1160,158 +1156,3 @@ describe("draft-aware at a full held quota (#2430)", () => {
expect(f.outbox.retentionUsage("a").count).toBe(100);
});
});
describe("#3076: a draft typed between the guard's read and the paste", () => {
const draft = "Please send it to the team. Also";
const notice = "[OpenRig watchdog scheduler · policy: periodic-reminder · job: j]\nWake timer fired.";
const pasted = (f: ReturnType<typeof fixture>) => f.writes.filter(w => w.startsWith("paste:"));
const setActivity = (f: ReturnType<typeof fixture>, at: () => number | null) => {
vi.spyOn(f.tmux, "lastClientActivity").mockImplementation(async () => at());
};
it("is seen by the re-read after the settle wait: the notice is held and nothing is pasted into the person's input", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const waits: number[] = [];
// The person starts typing during the guard's wait right before the paste (the first read saw an empty input).
f.hooks.settle = ms => { waits.push(ms); if (!pasted(f).length) f.bodies.a = draft; };
const result = await f.send("watchdog-wake", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(waits[0]).toBe(300);
expect(pasted(f)).toEqual([]);
expect(f.submissions).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("holds on recent writable-client activity, also activity during the wait, and a later attempt delivers once quiet", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last: number | null = Date.now() - 1000;
setActivity(f, () => last);
const busy = await f.send("wake-1", "Wake timer fired.", { openrigNotice: true, verify: false });
expect(busy).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
last = Date.now() - 60_000;
f.hooks.settle = () => { last = Date.now(); }; // activity during the settle wait, nothing drawn yet
f.advance();
const during = await f.send("wake-2", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(during).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
last = Date.now() - 60_000;
f.hooks.settle = undefined;
f.advance();
const sent = await f.send("wake-3", "Wake timer fired a third time.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
expect(f.submissions).toHaveLength(1);
});
it("counts the quiet window from the end of the whole second tmux reports: 3.5 s ago still holds, 4.5 s ago delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last = Date.now() - 3500; // activity at N.999 s reads as N: it may have been 2.5 s ago
setActivity(f, () => last);
const held = await f.send("whole-second", "Wake timer fired.", { openrigNotice: true, verify: false });
expect(held).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
last = Date.now() - 4500;
f.advance();
const sent = await f.send("whole-second-later", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("holds as unreadable when tmux can't report recent writable-client activity; with no writable client it delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
vi.spyOn(f.tmux, "lastClientActivity").mockRejectedValue(new Error("command timed out"));
const held = await f.send("activity-unreadable", notice, { openrigNotice: true, verify: false });
expect(held).toMatchObject({ sent: false, reason: "draft_input_unknown" });
expect(pasted(f)).toEqual([]);
setActivity(f, () => null);
f.advance();
const sent = await f.send("no-client", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
it("holds as unreadable when the input can't be read after the wait", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
f.hooks.settle = () => { if (!pasted(f).length) f.bodies.a = null; };
const result = await f.send("unreadable", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ sent: false, reason: "draft_input_unknown" });
expect(pasted(f)).toEqual([]);
});
it("runs the send's own waiting checks before the guard's last read, so a draft typed while they wait is seen", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
// A conditional send's checks await (a conversation lookup); the person types meanwhile. The adapter returns the
// guard's refusal as its result.
const result = await f.guard.operation("worker@test", () =>
f.tmux.sendText("worker@test", notice, undefined, { beforePaste: async () => { f.bodies.a = draft; } }));
expect(result).toMatchObject({ ok: false, code: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("applies to every automatic input under draft-aware, a deliberate send included, as the empty check already did", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
f.hooks.settle = () => { if (!pasted(f).length) f.bodies.a = draft; };
const result = await f.send("deliberate", "a message from another seat", { verify: false });
expect(result).toMatchObject({ sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
expect(f.bodies.a).toBe(draft);
});
it("the remaining gap: typing after the final check and before the paste leaves the notice beside the draft, never submitted or deleted", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let typed = false;
// The person types between the guard's last check and tmux's paste: the paste lands after their text.
f.hooks.paste = () => { if (!typed) { typed = true; f.bodies.a = draft; } };
const result = await f.send("final-boundary", notice, { openrigNotice: true, verify: false });
// Contamination: exactly one paste, and the input is the person's text followed by it, nothing removed.
expect(pasted(f)).toHaveLength(1);
const staged = pasted(f)[0]!.slice("paste:a:".length);
expect(f.bodies.a).toBe(draft + staged);
// Submission: none. No key at all reached the pane (no Enter, C-m or C-u), and the guard says why.
expect(f.writes.filter(w => w.startsWith("keys:a"))).toEqual([]);
expect(f.submissions).toEqual([]);
expect(result).toMatchObject({ ok: false });
expect(JSON.stringify(result)).toContain("draft_input_changed");
});
it("checks the activity again after the final read: activity that appears at that read holds the paste", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
let last: number | null = Date.now() - 60_000;
setActivity(f, () => last);
const real = f.tmux.captureComposerSnapshot.getMockImplementation()!;
let armed = false;
f.hooks.settle = () => { armed = true; };
// The composer still reads empty at the final read, but a writable client has activity right then.
vi.spyOn(f.tmux, "captureComposerSnapshot").mockImplementation(async (name: string) => {
const shot = await real(name);
if (armed) { armed = false; last = Date.now(); }
return shot;
});
const result = await f.send("activity-at-final-read", notice, { openrigNotice: true, verify: false });
expect(result).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
});
it("reads tmux's real list-clients output end to end: an attach in this second holds, a quiet client delivers", async () => {
const f = fixture(undefined, { runtime: "claude-code", appendPaste: true });
await f.enable();
const now = () => Math.floor(Date.now() / 1000);
f.hooks.clients = () => `1|${now()}\n0|${now()}\n`; // a viewer tile and a writable client attached this second
const held = await f.send("attach-now", notice, { openrigNotice: true, verify: false });
expect(held).toMatchObject({ outcome: "retained", sent: false, reason: "draft_input_busy" });
expect(pasted(f)).toEqual([]);
f.hooks.clients = () => `1|${now()}\n0|${now() - 10}\n`; // only the read-only tile is active
f.advance();
const sent = await f.send("attach-quiet", "Wake timer fired again.", { openrigNotice: true, verify: false });
expect(sent).toMatchObject({ ok: true });
});
});
@@ -23,10 +23,6 @@ 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')";
@@ -260,39 +256,26 @@ describe("slice-04 ps/summary event-loop regression", () => {
})();
if (!nodes) probe = startProbe();
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(); }));
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;
}
await Promise.all(reqs);
stop = true;
await probe;
probe = null;
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(); }));
}
// 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;
@@ -323,7 +306,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}/${nodes ? psStatuses.length : 24}`
+ `${nodes ? "nodes" : "ps"} 200s ${psStatuses.filter((st) => st === 200).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) });
@@ -332,13 +315,10 @@ describe("slice-04 ps/summary event-loop regression", () => {
}
// Teardown is guaranteed done above; now assert (the budget pin is the LAST assertion).
if (nodes) expect(psStatuses.length).toBeGreaterThanOrEqual(24);
else expect(psStatuses).toHaveLength(24);
expect(psStatuses).toHaveLength(24);
expect(psStatuses.every((s) => s === 200)).toBe(true);
if (nodes) {
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).toHaveLength(24);
expect(nodeCounts.every(n => n > 0)).toBe(true);
expect(baselineHealth).toHaveLength(8);
expect(baselineQueue).toHaveLength(8);
-41
View File
@@ -51,47 +51,6 @@ describe("TmuxAdapter.listSessionClientTerminals", () => {
});
});
describe("TmuxAdapter.lastClientActivity (#3076)", () => {
const activity = (stdout: string) => adapterFor(mockExec({ "list-clients": { stdout } })).lastClientActivity("op@kernel");
it("is the latest activity of a writable client, in milliseconds", async () => {
expect(await activity("0|1790000100\n0|1790000105\n")).toBe(1790000105000);
});
it("does not count a read-only client (a viewer tile)", async () => {
expect(await activity("1|1790000200\n0|1790000100\n")).toBe(1790000100000);
expect(await activity("1|1790000200\n")).toBeNull();
});
it("counts an attach as recent activity: a key in the same second as the attach can't be told apart from it", async () => {
// tmux would report client_created == client_activity for both an attach alone and an attach plus a key that second.
expect(await activity("0|1790000300\n")).toBe(1790000300000);
});
it("is null with no client", async () => {
expect(await activity("")).toBeNull();
expect(await activity("\n")).toBeNull();
});
it("throws on a row it can't read, alone or next to a readable one, so the guard holds", async () => {
for (const stdout of ["0|abc\n", "0|0\n", "0\n", "2|1790000100\n", "0|1790000000|1790000100\n", "0|1790000100\n0|abc\n"]) {
await expect(activity(stdout)).rejects.toThrow();
}
});
it("throws when tmux can't say", async () => {
for (const error of [new Error("can't find session: op@kernel"), NO_SERVER_ERROR, new Error("command timed out")]) {
await expect(adapterFor(mockExec({ "list-clients": { error } })).lastClientActivity("op@kernel")).rejects.toThrow();
}
});
it("asks tmux for the read-only flag and the activity of each client of the session", async () => {
const commands: string[] = [];
await adapterFor(async command => { commands.push(command); return ""; }).lastClientActivity("op@kernel");
expect(commands.some(c => c.includes("list-clients") && c.includes("#{client_readonly}|#{client_activity}"))).toBe(true);
});
});
describe("TmuxAdapter", () => {
it("starts only an empty terminal server and reconciles repeated requests", async () => {
let live = false;
@@ -158,7 +158,6 @@ async function driveEveryMethod(): Promise<RunCall[]> {
await a.readPaneLastActivity(SESSION);
await a.listClients();
await a.listSessionClientTerminals(SESSION);
await a.lastClientActivity(SESSION); // #3076: the client activity read right before an automatic paste
await a.switchClient("/dev/ttys001", SESSION);
// A runtime switch's exchange: two session-id reads, then the guarded if-shell swap (nested quoting); and its env writes.
await a.swapRuntimeSwitchPanes({ nodeId: "n1", stagingSession: "staging'q a", canonicalSession: SESSION, canonicalPane: "%1", stagedPane: "%1" });
+1 -1
View File
@@ -10,7 +10,7 @@ export function toConnectedAssistants(body: unknown): ConnectedAssistant[] | nul
if (!Array.isArray(members)) return null;
return members.flatMap((member: Record<string, unknown>) => {
if (typeof member?.seat !== "string" || !member.seat) return [];
const known = typeof member.status === "string" && Object.hasOwn(CHECK_IN_DISPLAY, member.status);
const known = typeof member.status === "string" && member.status in CHECK_IN_DISPLAY;
return [{ seat: member.seat, checkIn: {
status: known ? member.status as CheckInStatus : "unknown",
lastCheckInAt: typeof member.lastCheckInAt === "string" ? member.lastCheckInAt : null,
+1 -5
View File
@@ -958,11 +958,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot, contentWidth: numbe
{ text: `Its seats aren't read in this view: rig ps --host ${rig.remoteHost} --nodes --rig ${rig.name}` },
];
if (rig.inventoryNotLoaded) return [{ text: `Reading ${rig.name}…` }];
if (rig.inventoryUnavailable) return [
{ text: `Inventory unavailable for ${rig.name} · refresh to Retry` },
...(leaf?.kind === "pod" ? [] : connectedAssistantLines(host.connectedAssistants, host.connectedAssistantsUnavailable,
rig.name, state.filter, contentWidth, state.timeZone)),
];
if (rig.inventoryUnavailable) return [{ text: `Inventory unavailable for ${rig.name} · refresh to Retry` }];
const podFilter = leaf?.kind === "pod" ? leaf.name : null;
const all = rig.pods.flatMap((p) => p.agents.map((a) => ({ pod: p.name, ...a })));
const rows = all
-28
View File
@@ -782,34 +782,6 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(instanceLines).not.toContain("not shown");
});
it("shows inherited-property check-in statuses as unknown", async () => {
const hosted = { members: ["constructor", "toString"].map((status, i) => ({
seat: `guest${i}@myrig`, status, reason: "unrecognized reason",
})) };
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/queue/hosted-members": hosted }));
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const lines = renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(lines).toContain("guest0@myrig unknown");
expect(lines).toContain("guest1@myrig unknown");
expect(lines).not.toContain("unrecognized reason");
});
it("shows connected check-ins beside a failed rig nodes read", async () => {
const hosted = { members: [
{ seat: "chat@myrig", status: "on-request", lastCheckInAt: null },
{ seat: "other@downrig", status: "checking-in", lastCheckInAt: null },
] };
const snap = await hydrateSnapshot(fixtureClient({ "/api/rigs/01JRIG/nodes": { status: 500 } },
{ "/api/queue/hosted-members": hosted }));
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "drill", resource: "rig", name: "myrig" });
const lines = renderScreen(view.get(), snap, { cols: 160, rows: 60 }).lines.join("\n");
expect(lines).toContain("Inventory unavailable for myrig");
expect(lines).toContain("chat@myrig on request:");
expect(lines).not.toContain("other@downrig");
});
it("a known-empty list of connected assistants stays quiet", async () => {
const snap = await hydrateSnapshot(fixtureClient());
expect(snap.hosts[0]!.connectedAssistants).toEqual([]);