mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 18:48:31 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6604c72a94 |
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
/** Rowids one statement may cover before the read yields to the event loop. Soft: one large row's conversion, native
|
||||
* I/O or a correlated subquery on a matching row can still make a window take longer. */
|
||||
const WINDOW_ROWS = 256;
|
||||
/** Rows read per slice, and the time a slice may take, before yielding to the event loop. Both are soft: one native
|
||||
* step or row conversion can take longer. */
|
||||
const SLICE_ROWS = 64;
|
||||
const SLICE_MS = 8;
|
||||
|
||||
/**
|
||||
* The main database file when the execution view's candidate read can run cooperatively on its own connection, or
|
||||
@@ -17,66 +18,45 @@ export function cooperativeReadFile(db: Database.Database): string | null {
|
||||
return db.pragma("journal_mode", { simple: true }) === "wal" ? main.file : null;
|
||||
}
|
||||
|
||||
/** One cooperative read: its rows, the windows it read, and the longest synchronous stretch it ran between yields. */
|
||||
export interface CooperativeRead<Row> {
|
||||
rows: Row[];
|
||||
windows: number;
|
||||
longestStretchMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every row of one SELECT over `table` (a table name from code, never input) on a new readonly connection, one
|
||||
* rowid window at a time, yielding to the event loop between windows so unrelated requests progress during a long
|
||||
* scan. `sql` reads one window: it binds the window's exclusive lower and inclusive upper rowid first
|
||||
* (`table.rowid > ? AND table.rowid <= ?`), then `binds`. Each statement steps over at most `windowRows` rowids however
|
||||
* few rows its filter matches; a cursor that yields between returned rows can scan the whole table in one step when
|
||||
* matches are rare. One read transaction keeps one snapshot for every window, and rows come back in rowid order. The
|
||||
* transaction and the connection end before returning, on every path; a read error is rethrown as-is and no partial
|
||||
* rows are returned.
|
||||
* Reads every row of one SELECT on a new readonly connection to `file`, yielding to the event loop between slices so
|
||||
* unrelated requests progress during a long scan. One cursor keeps one snapshot for the whole read. The cursor and
|
||||
* the connection are closed before returning, on every path; a read error is rethrown as-is and no partial rows are
|
||||
* returned.
|
||||
*/
|
||||
export async function readRowsCooperatively<Row>(
|
||||
file: string,
|
||||
table: string,
|
||||
sql: string,
|
||||
binds: unknown[],
|
||||
open: (file: string) => Database.Database = (path) => new Database(path, { readonly: true, fileMustExist: true }),
|
||||
windowRows = WINDOW_ROWS,
|
||||
): Promise<CooperativeRead<Row>> {
|
||||
): Promise<Row[]> {
|
||||
let reader: Database.Database | undefined;
|
||||
let cursor: IterableIterator<unknown> | undefined;
|
||||
let rows: Row[] = [];
|
||||
let windows = 0, longestStretchMs = 0, stretchStart = performance.now();
|
||||
const endStretch = () => { longestStretchMs = Math.max(longestStretchMs, performance.now() - stretchStart); };
|
||||
let failure: { error: unknown } | undefined;
|
||||
let cleanupFailure: { error: unknown } | undefined;
|
||||
try {
|
||||
reader = open(file);
|
||||
reader.exec("BEGIN");
|
||||
// The first read takes the snapshot every window shares. Each scalar subquery seeks one end of the rowid b-tree;
|
||||
// one SELECT of MIN and MAX together would scan a whole index instead.
|
||||
const { lo, hi } = reader.prepare(`SELECT (SELECT MIN(rowid) FROM ${table}) AS lo, (SELECT MAX(rowid) FROM ${table}) AS hi`)
|
||||
.get() as { lo: number | null; hi: number | null };
|
||||
const window = reader.prepare(sql);
|
||||
if (lo !== null && hi !== null) {
|
||||
for (let after = lo - 1; after < hi;) {
|
||||
const through = Math.min(after + windowRows, hi);
|
||||
for (const row of window.all(after, through, ...binds)) rows.push(row as Row);
|
||||
windows++;
|
||||
after = through;
|
||||
if (after < hi) {
|
||||
endStretch();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
stretchStart = performance.now();
|
||||
}
|
||||
cursor = reader.prepare(sql).iterate(...binds);
|
||||
for (;;) {
|
||||
const sliceStart = performance.now();
|
||||
let read = 0;
|
||||
let step = cursor.next();
|
||||
while (!step.done) {
|
||||
rows.push(step.value as Row);
|
||||
if (++read >= SLICE_ROWS || performance.now() - sliceStart >= SLICE_MS) break;
|
||||
step = cursor.next();
|
||||
}
|
||||
if (step.done) break;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
} catch (error) {
|
||||
failure = { error };
|
||||
} finally {
|
||||
try { if (reader?.inTransaction) reader.exec("ROLLBACK"); } catch (error) { cleanupFailure = { error }; }
|
||||
try { cursor?.return?.(); } catch (error) { cleanupFailure = { error }; }
|
||||
try { reader?.close(); } catch (error) { cleanupFailure ??= { error }; }
|
||||
}
|
||||
if (failure) { rows = []; throw failure.error; }
|
||||
if (cleanupFailure) { rows = []; throw cleanupFailure.error; }
|
||||
endStretch();
|
||||
return { rows, windows, longestStretchMs };
|
||||
return rows;
|
||||
}
|
||||
@@ -54,7 +54,6 @@ import { resolveLegacyTopologyRigsRoot } from "./user-settings/settings-store.js
|
||||
import { BUILD_INFO, type BuildInfo } from "../build-info.js";
|
||||
import { validateMissionComposition } from "./lifecycle-manifest.js";
|
||||
import { cooperativeReadFile, readRowsCooperatively } from "./execution-candidate-reader.js";
|
||||
import { recordSlowDiagnostic } from "./sync-site-wrap.js";
|
||||
|
||||
export const INDETERMINATE = "INDETERMINATE" as const;
|
||||
export type Indeterminate = typeof INDETERMINATE;
|
||||
@@ -749,7 +748,6 @@ async function gitAncestor(exec: ExecutionViewDeps["exec"], repoCtx: string, sha
|
||||
}
|
||||
|
||||
export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { mission?: string; rig?: string; project?: string }): Promise<Record<string, unknown>> {
|
||||
const projectionStarted = performance.now();
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const exec = deps.exec ?? defaultExec;
|
||||
const buildInfo = deps.buildInfo ?? BUILD_INFO;
|
||||
@@ -808,30 +806,23 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
// post_claim_motion feeds only derivePickup's legacy fallback, which pending and in-progress rows reach: a blocked row
|
||||
// returns parked and a closed row terminal before the count is read. Every row and body is still selected here (the
|
||||
// ladder, EC-3 and wave-map evidence need historical rows); only their unused history count is skipped.
|
||||
const candidateSelect = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
|
||||
const candidateSql = `SELECT qitem_id, source_session, destination_session, state, tags, body, summary, claimed_at,
|
||||
last_heartbeat, blocked_on, ts_created, ts_updated,
|
||||
CASE WHEN queue_items.state IN ('pending','in-progress') THEN (SELECT COUNT(*) FROM queue_transitions t
|
||||
WHERE t.qitem_id = queue_items.qitem_id
|
||||
AND t.ts > queue_items.claimed_at
|
||||
AND t.transition_note IS NOT 'claimed') ELSE 0 END AS post_claim_motion
|
||||
FROM queue_items`;
|
||||
FROM queue_items
|
||||
WHERE (${missionWhere})`;
|
||||
const candidateBinds = missionLikes.flatMap((like) => [like, like]);
|
||||
// On a WAL database file this full scan runs on its own readonly connection in rowid windows, yielding between
|
||||
// windows so unrelated work can run even when few rows match; the same filter runs inside each window, so the rows
|
||||
// are the ones the single statement returns. A native open, a large row or a window's matching rows can still
|
||||
// block for a while, since the window bound is soft. It sees committed data as of its first read. Inside a
|
||||
// transaction, or for a memory or non-WAL database, it stays one synchronous read on the main handle (no await on
|
||||
// that branch).
|
||||
// On a WAL database file this full scan runs on its own readonly cursor and yields between slices so unrelated
|
||||
// work can run; a native open, prepare or step can still block, since the slice budget is soft. It sees committed
|
||||
// data as of its first step. Inside a transaction, or for a memory or non-WAL database, it stays a synchronous
|
||||
// read on the main handle (no await on that branch).
|
||||
const cooperativeFile = cooperativeReadFile(deps.db);
|
||||
const candidateStarted = performance.now();
|
||||
const candidateRead = cooperativeFile === null
|
||||
? null
|
||||
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, "queue_items",
|
||||
`${candidateSelect} WHERE queue_items.rowid > ? AND queue_items.rowid <= ? AND (${missionWhere})`, candidateBinds);
|
||||
const candidateRows = candidateRead
|
||||
? candidateRead.rows
|
||||
: deps.db.prepare(`${candidateSelect} WHERE (${missionWhere})`).all(...candidateBinds) as QueueRowLite[];
|
||||
const candidateMs = performance.now() - candidateStarted;
|
||||
const candidateRows = cooperativeFile === null
|
||||
? deps.db.prepare(candidateSql).all(...candidateBinds) as QueueRowLite[]
|
||||
: await readRowsCooperatively<QueueRowLite>(cooperativeFile, candidateSql, candidateBinds);
|
||||
|
||||
const rows = candidateRows
|
||||
.filter(row => mission === INDETERMINATE || rowBindsToMission(row.tags, missionRefs))
|
||||
@@ -1366,7 +1357,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
df_margin: dfMargin,
|
||||
};
|
||||
|
||||
const view = {
|
||||
return {
|
||||
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
|
||||
view: "execution",
|
||||
readiness,
|
||||
@@ -1419,19 +1410,4 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
|
||||
q5_park: q5,
|
||||
q6_parallelism: q6,
|
||||
};
|
||||
// Measurement only, for a slow projection: its elapsed time, the candidate read's elapsed time, and the candidate
|
||||
// read's longest synchronous stretch, which a whole-view or awaited span cannot show. The main-handle read is one
|
||||
// stretch. Counts and times only; no row content.
|
||||
const projectionMs = performance.now() - projectionStarted;
|
||||
recordSlowDiagnostic(projectionMs, {
|
||||
schema: "openrig.execution-view/v1",
|
||||
utc: new Date().toISOString(),
|
||||
projectionMs,
|
||||
candidateMs,
|
||||
candidateRows: candidateRows.length,
|
||||
candidateRead: candidateRead ? "windowed" : "main-handle",
|
||||
candidateWindows: candidateRead?.windows ?? null,
|
||||
candidateLongestStretchMs: candidateRead?.longestStretchMs ?? candidateMs,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SLOW_OPERATION_THRESHOLD_MS, type SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
import type { SlowOperationInstrumentation } from "./slow-op-recorder.js";
|
||||
|
||||
let recorder: SlowOperationInstrumentation | undefined;
|
||||
|
||||
@@ -21,13 +21,3 @@ export function runSyncSite<T>(site: string, fn: () => T): T {
|
||||
export async function runAsyncSite<T>(site: string, fn: () => Promise<T>): Promise<T> {
|
||||
return recorder?.runStage ? recorder.runStage(site, fn) : fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers one diagnostic record to the configured recorder's bounded log, only when `elapsedMs` reached the slow
|
||||
* threshold, so a hot path adds no record per call. Measurement only: no recorder, a dropped record or a throwing
|
||||
* recorder changes nothing for the caller.
|
||||
*/
|
||||
export function recordSlowDiagnostic(elapsedMs: number, record: Record<string, unknown>): void {
|
||||
if (!(elapsedMs >= SLOW_OPERATION_THRESHOLD_MS)) return;
|
||||
try { recorder?.recordDiagnostic?.(record); } catch { /* the recorder reports its own health */ }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,8 +17,6 @@ import { viewsCustomSchema } from "../src/db/migrations/030_views_custom.js";
|
||||
import { EventBus } from "../src/domain/event-bus.js";
|
||||
import { ViewProjector } from "../src/domain/view-projector.js";
|
||||
import { cooperativeReadFile, readRowsCooperatively } from "../src/domain/execution-candidate-reader.js";
|
||||
import { buildExecutionView } from "../src/domain/execution-view.js";
|
||||
import { configureSyncSiteRecorder, recordSlowDiagnostic } from "../src/domain/sync-site-wrap.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const handles: Database.Database[] = [];
|
||||
@@ -55,69 +53,42 @@ function turns(): { count: () => number; stop: () => void } {
|
||||
return { count: () => count, stop: () => { stopped = true; } };
|
||||
}
|
||||
|
||||
/** One window of t, the way the execution view binds its windows: exclusive lower and inclusive upper rowid first. */
|
||||
const WINDOW_SQL = "SELECT id, v FROM t WHERE id > ? AND id <= ? ORDER BY id";
|
||||
|
||||
describe("cooperative candidate reads", () => {
|
||||
it("reads every row in rowid order on its own connection, a window at a time, letting other callbacks run between windows", async () => {
|
||||
it("reads every row in order on its own connection, letting other callbacks run between slices", async () => {
|
||||
const { file } = walTable(500);
|
||||
const loop = turns(), reader = recordingOpen();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, [], reader.open, 64);
|
||||
const rows = await readRowsCooperatively<{ id: number }>(file, "SELECT id, v FROM t ORDER BY id", [], reader.open);
|
||||
loop.stop();
|
||||
expect(reader.readers.map((db) => [db.readonly, db.open, db.inTransaction])).toEqual([[true, false, false]]); // readonly, and closed before returning
|
||||
expect(read.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(read.windows).toBe(8);
|
||||
expect(reader.readers.map((db) => [db.readonly, db.open])).toEqual([[true, false]]); // readonly, and closed before returning
|
||||
expect(rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(loop.count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("yields between windows even when the filter matches almost nothing", async () => {
|
||||
const { file } = walTable(2000);
|
||||
const loop = turns();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", "SELECT id FROM t WHERE id > ? AND id <= ? AND v LIKE ?", ['%"a":2000}%']);
|
||||
loop.stop();
|
||||
expect(read.rows).toEqual([{ id: 2000 }]);
|
||||
expect(read.windows).toBe(8); // 2000 rowids, 256 per window
|
||||
// A cursor that yields only between returned rows reaches its one match in a single native step, with no turn between.
|
||||
expect(loop.count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("reads an empty table as no rows, and steps over rowid gaps", async () => {
|
||||
const { file, db } = walTable(0);
|
||||
expect(await readRowsCooperatively(file, "t", WINDOW_SQL, [])).toMatchObject({ rows: [], windows: 0 });
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (1, 'a'), (1000, 'b')").run();
|
||||
const read = await readRowsCooperatively<{ id: number }>(file, "t", WINDOW_SQL, []);
|
||||
expect(read.rows.map((r) => r.id)).toEqual([1, 1000]);
|
||||
expect(read.windows).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps one snapshot for every window while writers commit between windows; the next read sees them", async () => {
|
||||
it("keeps one snapshot for the whole read while writers commit between slices; the next read sees them", async () => {
|
||||
const { file, db } = walTable(500);
|
||||
// Runs at the reader's first yield, after its first window: every change lands in a window it has not read yet.
|
||||
// Runs at the reader's first yield, after its cursor has started.
|
||||
setImmediate(() => db.transaction(() => {
|
||||
db.prepare("UPDATE t SET v = 'changed' WHERE id = 500").run();
|
||||
db.prepare("DELETE FROM t WHERE id = 400").run();
|
||||
db.prepare("UPDATE t SET v = 'changed' WHERE id = 1").run();
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (501, 'new')").run();
|
||||
})());
|
||||
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, [], undefined, 64);
|
||||
expect(during.rows.map((r) => r.id)).toEqual(Array.from({ length: 500 }, (_, i) => i + 1));
|
||||
expect(during.rows.at(-1)).toEqual({ id: 500, v: JSON.stringify({ a: 500 }) });
|
||||
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "t", WINDOW_SQL, []);
|
||||
expect(after.rows).toHaveLength(500);
|
||||
expect(after.rows.some((r) => r.id === 400)).toBe(false);
|
||||
expect(after.rows.slice(-2)).toEqual([{ id: 500, v: "changed" }, { id: 501, v: "new" }]);
|
||||
const during = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
|
||||
expect(during).toHaveLength(500);
|
||||
expect(during[0]!.v).toBe(JSON.stringify({ a: 1 }));
|
||||
const after = await readRowsCooperatively<{ id: number; v: string }>(file, "SELECT id, v FROM t ORDER BY id", []);
|
||||
expect(after).toHaveLength(501);
|
||||
expect(after[0]!.v).toBe("changed");
|
||||
});
|
||||
|
||||
it("rethrows a native error raised in a later window, returns nothing, and leaves no reader pinning the WAL", async () => {
|
||||
it("rethrows a native error raised mid-read, returns nothing, and leaves no reader pinning the WAL", async () => {
|
||||
const { file, db } = walTable(500, 300);
|
||||
const reader = recordingOpen();
|
||||
const sql = "SELECT json_extract(v, '$.a') AS a FROM t WHERE id > ? AND id <= ? ORDER BY id";
|
||||
const read = readRowsCooperatively(file, "t", sql, [], reader.open, 64);
|
||||
const read = readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [], reader.open);
|
||||
await expect(read).rejects.toMatchObject({ name: "SqliteError", code: "SQLITE_ERROR" });
|
||||
expect(reader.readers.map((db) => db.open)).toEqual([false]);
|
||||
db.prepare("INSERT INTO t(id, v) VALUES (501, '{}')").run();
|
||||
expect(db.pragma("wal_checkpoint(TRUNCATE)")).toEqual([{ busy: 0, log: 0, checkpointed: 0 }]);
|
||||
db.prepare("DELETE FROM t WHERE id = 300").run();
|
||||
expect((await readRowsCooperatively(file, "t", sql, [])).rows).toHaveLength(500);
|
||||
expect(await readRowsCooperatively(file, "SELECT json_extract(v, '$.a') AS a FROM t ORDER BY id", [])).toHaveLength(500);
|
||||
});
|
||||
|
||||
it("stays on the main handle inside a transaction and for memory or non-WAL databases", () => {
|
||||
@@ -139,18 +110,12 @@ describe("the projector's execution queue", () => {
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
return db;
|
||||
}
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[] | null, body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
|
||||
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body, claimed_at, last_heartbeat, blocked_on)
|
||||
VALUES (?, '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', ?, 'normal', 'light', ?, ?, ?, NULL, ?)`)
|
||||
.run(id, state, tags && JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
|
||||
.run(id, state, JSON.stringify(tags), body, claimedAt, state === "blocked" ? "external:review" : null);
|
||||
const laneIds = (doc: Record<string, unknown>) => (doc.q1_lanes as Array<{ qitem_id: string }>).map((l) => l.qitem_id).sort();
|
||||
const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
|
||||
const withoutLiveDisk = (doc: unknown) => {
|
||||
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
|
||||
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
|
||||
return copy;
|
||||
};
|
||||
|
||||
it("builds one execution at a time in arrival order, each reading fresh, and a failed build does not block the next", async () => {
|
||||
const db = fixture(tempFile("queue.sqlite"));
|
||||
@@ -211,90 +176,14 @@ describe("the projector's execution queue", () => {
|
||||
seed(memory); seed(file);
|
||||
expect(cooperativeReadFile(memory)).toBeNull();
|
||||
expect(cooperativeReadFile(file)).not.toBeNull();
|
||||
// The free-disk reading is taken live at read time (fs.statfsSync); every other field must match.
|
||||
const withoutLiveDisk = (doc: unknown) => {
|
||||
const copy = structuredClone(doc) as { q6_parallelism?: { df_margin?: { available_kib?: unknown } } };
|
||||
if (copy.q6_parallelism?.df_margin) delete copy.q6_parallelism.df_margin.available_kib;
|
||||
return copy;
|
||||
};
|
||||
const legacy = await documentOf(memory);
|
||||
expect(withoutLiveDisk(await documentOf(file))).toEqual(withoutLiveDisk(legacy));
|
||||
expect((legacy as { q1_lanes: unknown[] }).q1_lanes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("binds the same rows through the windowed read when the mission filter matches few of them, edge cases included", async () => {
|
||||
const missionsRoot = path.dirname(tempFile("missions"));
|
||||
fs.mkdirSync(path.join(missionsRoot, "release-9"));
|
||||
fs.writeFileSync(path.join(missionsRoot, "release-9", "SPEC.md"), "---\nid: OPR_9\n---\n\n# Fixture mission\n");
|
||||
const edges: Array<[id: string, state: string, tags: string[] | null, body: string]> = [
|
||||
["qitem-tagged", "in-progress", ["mission:release-9", "project:p1", "slice:S1"], "Tagged."],
|
||||
["qitem-alias-tag", "in-progress", ["mission:OPR_9", "project:p2", "slice:S2"], "Tagged with the mission's SPEC id."],
|
||||
["qitem-case", "in-progress", ["mission:RELEASE-9", "slice:S3"], "Tagged in another ASCII case."],
|
||||
["qitem-longer-tag", "in-progress", ["mission:release-90", "slice:S4"], "Another mission whose name contains this one."],
|
||||
["qitem-other-tag", "in-progress", ["mission:other", "slice:S5"], "Tagged elsewhere; mentions release-9."],
|
||||
["qitem-body", "in-progress", ["slice:S6", "project:p1"], "Untagged work for release-9."],
|
||||
["qitem-wildcard", "in-progress", ["slice:S7"], "Untagged; mentions OPRx9, which LIKE's _ matches."],
|
||||
["qitem-null-tags", "in-progress", null, "Slice: S8\nNo tags; mentions release-9."],
|
||||
["qitem-empty-body", "in-progress", ["mission:release-9", "slice:S9"], ""],
|
||||
["qitem-null-tags-empty-body", "in-progress", null, ""],
|
||||
["qitem-moved", "in-progress", ["mission:release-9", "slice:S10"], "Claimed, then moved three times."],
|
||||
];
|
||||
// 700 unrelated rows around the edge rows, so matches are sparse and fall in different windows.
|
||||
const seed = (db: Database.Database) => db.transaction(() => {
|
||||
const fill = (from: number, to: number) => {
|
||||
for (let i = from; i < to; i++) insertRow(db, `qitem-filler-${String(i).padStart(4, "0")}`, "done", ["slice:S1"], `Unrelated work ${i}.`);
|
||||
};
|
||||
fill(0, 300);
|
||||
for (const [id, state, tags, body] of edges.slice(0, 4)) insertRow(db, id, state, tags, body);
|
||||
fill(300, 600);
|
||||
for (const [id, state, tags, body] of edges.slice(4)) insertRow(db, id, state, tags, body);
|
||||
fill(600, 700);
|
||||
for (let n = 0; n < 3; n++) {
|
||||
db.prepare("INSERT INTO queue_transitions (qitem_id, ts, state, transition_note, actor_session) VALUES ('qitem-moved', ?, 'in-progress', 'moved', 'builder@r')")
|
||||
.run(`2026-10-25T11:3${n}:00.000Z`);
|
||||
}
|
||||
})();
|
||||
const memory = fixture(), file = fixture(tempFile("sparse.sqlite"));
|
||||
seed(memory); seed(file);
|
||||
expect(cooperativeReadFile(file)).not.toBeNull();
|
||||
const deps = (db: Database.Database) => ({ db, now: () => NOW, slicesRoot: () => missionsRoot, rigsRoot: () => "/nonexistent-rigs-root",
|
||||
exec: async (): Promise<string> => { throw new Error("not a repository"); } });
|
||||
for (const project of [undefined, "p1"]) {
|
||||
const legacy = await buildExecutionView(deps(memory), { mission: "release-9", project });
|
||||
expect(withoutLiveDisk(await buildExecutionView(deps(file), { mission: "release-9", project }))).toEqual(withoutLiveDisk(legacy));
|
||||
expect(laneIds(legacy)).toEqual(project ? ["qitem-body", "qitem-tagged"] : [
|
||||
"qitem-alias-tag", "qitem-body", "qitem-case", "qitem-empty-body", "qitem-moved", "qitem-null-tags", "qitem-tagged", "qitem-wildcard",
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("slow execution projections in the slow-operation log", () => {
|
||||
afterEach(() => configureSyncSiteRecorder(undefined));
|
||||
|
||||
it("offers a diagnostic only at the slow threshold, and a throwing recorder changes nothing for the caller", () => {
|
||||
const offered: unknown[] = [];
|
||||
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
|
||||
recordSlowDiagnostic(249.9, { n: 1 });
|
||||
recordSlowDiagnostic(250, { n: 2 });
|
||||
recordSlowDiagnostic(Number.NaN, { n: 3 });
|
||||
expect(offered).toEqual([{ n: 2 }]);
|
||||
configureSyncSiteRecorder({ recordDiagnostic: () => { throw new Error("recorder broke"); } });
|
||||
expect(() => recordSlowDiagnostic(300, { n: 4 })).not.toThrow();
|
||||
});
|
||||
|
||||
it("records a slow projection's elapsed times and the candidate read's longest stretch, with no row content", async () => {
|
||||
const offered: Record<string, unknown>[] = [];
|
||||
configureSyncSiteRecorder({ recordDiagnostic: (record) => { offered.push(record); return true; } });
|
||||
const db = track(createDb(tempFile("slow.sqlite")));
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
|
||||
db.prepare(`INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, priority, tier, tags, body)
|
||||
VALUES ('qitem-slow', '2026-10-25T10:00:00.000Z', '2026-10-25T10:00:00.000Z', 'lead@r', 'builder@r', 'in-progress', 'normal', 'light', ?, ?)`)
|
||||
.run(JSON.stringify(["slice:S1"]), "Private words.\nworktree_path=/nowhere/slow\n");
|
||||
// Each Git probe of the row's worktree takes 260 ms, so the projection crosses the 250 ms threshold.
|
||||
await buildExecutionView({ db, now: () => new Date("2026-10-25T12:00:00.000Z"), slicesRoot: () => null, rigsRoot: () => "/nonexistent-rigs-root",
|
||||
exec: async (): Promise<string> => { await new Promise((resolve) => setTimeout(resolve, 260)); throw new Error("not a repository"); } });
|
||||
expect(offered).toHaveLength(1);
|
||||
const record = offered[0]!;
|
||||
expect(record).toMatchObject({ schema: "openrig.execution-view/v1", candidateRead: "windowed", candidateRows: 1, candidateWindows: 1 });
|
||||
expect(record.projectionMs).toBeGreaterThanOrEqual(250);
|
||||
expect(record.candidateMs).toBeLessThanOrEqual(record.projectionMs as number);
|
||||
expect(record.candidateLongestStretchMs).toBeLessThanOrEqual(record.candidateMs as number);
|
||||
expect(Object.keys(record).sort()).toEqual(["candidateLongestStretchMs", "candidateMs", "candidateRead", "candidateRows", "candidateWindows", "projectionMs", "schema", "utc"]);
|
||||
expect(JSON.stringify(record)).not.toMatch(/Private|nowhere|builder@r|S1/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user