Compare commits

..
Author SHA1 Message Date
dev60-review bdaa8717bd feat(plugins): give the openrig plugin a PNG icon for Claude's directory
Claude's plugin directory takes a square PNG or JPEG icon of 512 to
2048 px at .claude-plugin/icon.png, not SVG, and uses it as the
listing icon only the first time a plugin is saved or submitted. The
openrig plugin had only assets/logo.svg. This adds that mark rendered
at 1024x1024 (RGBA PNG, 53,823 bytes).
2026-11-12 01:31:02 +00:00
dev60-review b68742c3c2 fix(plugins): give each plugin manifest an https repository URL
Claude's plugin directory doesn't accept the github: shorthand in a
plugin's repository field (LISTING_URL_SCHEME) and drops it from the
listing preview. The five manifests of the openrig and openrig-core
plugins (Claude, Codex and the shared plugin.json) now carry
https://github.com/... URLs for the same repositories.
2026-11-12 01:23:59 +00:00
41 changed files with 104 additions and 1017 deletions

No files matched your search

+1 -3
View File
@@ -152,9 +152,7 @@ OpenRig. Two repairs aren't in this release: see Known limits.
and the stop wasn't exercised. For an adopted session, such as a seat joined
with `rig attach --self`, `rig seat stop` still suggests `rig down`, which
leaves that terminal running too. Release it with `rig unclaim <session>`
(that leaves its process running), then end that process. If it runs in tmux,
also end the underlying tmux session, including any remaining shell; closing
only its viewing window leaves that session running. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
(that leaves its process running), then close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
- **Codex working counter:** a capture that doesn't name its pane keeps the old
comparison, a restart that keeps both the pane and its process id isn't seen,
and a leftover live frame can count once until it expires. ([#2980](https://github.com/mvschwarz/openrig/pull/2980))
@@ -140,11 +140,7 @@ that gateway side too. For the pane side, read:
- `lastNudgeWireResult`: the wake send's result as stored (for example
`verified`, `delivered-ack-pending`, `indeterminate:…`, `retained:…`,
`failed:…`). A claim never rewrites it; a later wake replaces it. Each stored
form reads as one delivery fact (`readWakeOutcome`, `connection-contract.ts`).
A result no reader recognises reads `unknown`, never delivered: the wake ladder
treats it as an unconfirmed wake, which escalates past the window without a
re-send.
`failed:…`). A claim never rewrites it; a later wake replaces it.
- `lastNudgeResult`: the result as presented. It is the stored result, except
that a claimed row whose stored result is `delivered-ack-pending` or
`indeterminate:…` reads `claimed` (`presentedNudgeResult`). Unclaiming shows
-23
View File
@@ -112,29 +112,6 @@ OpenRig doesn't observe a hosted member between its calls, so it never reads idl
- `lastWake`: its latest ring and the result, among its 50 most recent rows.
- `rig roster show` and `find` show the last call; the last ring is in `rig_team`.
### Check-in status
A connected assistant (a roster member marked `hosted: true`) checks in each time it reads its own pending inbox (through the queue list, as the MCP list tool does
when it lists the member's own work). Its status compares its last check-in with the cadence its roster entry states
(`check_in_minutes`):
| Status | Meaning |
|---|---|
| `checking-in` | its last check-in is no older than twice its stated cadence |
| `check-in-overdue` | its last check-in is older than that: its schedule may be off or waiting for an approval |
| `no-check-in-yet` | no check-in recorded for it on this daemon, whether or not it states a cadence |
| `on-request` | no cadence stated: it checks in when a person prompts it |
| `disconnected` | reserved for a recorded revocation or removal of its connection. Nothing records one yet, so no member shows it |
| `unknown` | its check-ins couldn't be read, or its stated cadence can't be used; the reason says which |
A stated cadence is its person's statement: neither reading proves the assistant's own schedule ran, or that it picked
anything up. It's a status only. Work addressed to a member that isn't checking in still queues and is rung as before, a check-in
isn't pickup (pickup is its claim), and no check-in doesn't mean it's idle, failed or safe. `GET /api/queue/hosted-members`
gives this daemon's host id and each member on this host with its status, last check-in and stated cadence (with the
reason when it's unknown); `rig roster show` and `find` show it as `Check-in:`, for a team with or without terminal seats.
When the member has unclaimed work, the standing notice described above states the same status in its `check-ins:`
line; when that notice is filed doesn't change.
## Revoke
`rig mcp token revoke grok` refuses its next request. The rows it worked, and their answers, stay.
-4
View File
@@ -92,7 +92,3 @@ A member marked `"hosted": true` is a hosted member: a tool outside the terminal
`show` and `find` say it's hosted rather than unmatched. See [Hosted members](hosted-members.md).
Its entry can declare `wake.webhook`, the secret names for a trigger OpenRig rings when work lands for it
(see [Ring it when work lands](hosted-members.md#ring-it-when-work-lands)).
It can also declare `check_in_minutes`, how often it's expected to check its queue (a positive whole number of minutes;
an hourly schedule is `60`). That's what its person set up, not something OpenRig observes. Without it, the member
checks in when a person prompts it. A value that can't be used doesn't stop the roster loading: that member's check-in
status reads unknown, with the reason. See [Check-in status](hosted-members.md#check-in-status).
+2 -47
View File
@@ -5,10 +5,6 @@ import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { ConfigStore } from "../config-store.js";
import { mcpPresenceFilePath, readPresence } from "../mcp-presence.js";
import { CHECK_IN_DISPLAY, type CheckInStatus } from "@openrig/daemon/connection-contract";
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
import { DaemonClient } from "../client.js";
import { realDeps } from "./daemon.js";
import { parseRoster, readRosters, type Roster, type RosterMember, type RosterWake } from "@openrig/daemon/rosters";
// One roster parser, shared with the daemon (a hosted member's wake route reads the same files).
@@ -21,23 +17,6 @@ export interface RosterInventory {
sources: Row[];
/** This host's id, from the local daemon's own nodes; null when it has none to read it from. */
localHostId?: string | null;
/** The local daemon's hosted members with their check-in status, and that daemon's own host id (the host whose
* check-ins these are); null when it couldn't be read. */
checkIns?: { hostId: string | null; members: Row[] } | null;
}
/** Each hosted member's check-in status, from the local daemon. Null when the daemon isn't running or doesn't answer
* (an older daemon has no such read): every hosted member's status then reads unknown. */
export async function readHostedCheckIns(): Promise<RosterInventory["checkIns"]> {
try {
const status = await getDaemonStatus(realDeps());
if (status.state !== "running") return null;
const res = await new DaemonClient(getDaemonUrl(status)).get<unknown>("/api/queue/hosted-members");
return res.status < 400 && object(res.data) && Array.isArray(res.data.members)
? { hostId: text(res.data.hostId), members: res.data.members.filter(object) } : null;
} catch {
return null;
}
}
const object = (v: unknown): v is Row => !!v && typeof v === "object" && !Array.isArray(v);
const text = (v: unknown): string | null => typeof v === "string" && v.trim() ? v : null;
@@ -70,28 +49,7 @@ export async function readRosterInventory(): Promise<RosterInventory> {
sources.push({ scope, status: "unavailable", error: result.stderr.trim() || (error as Error).message });
}
}
return { readAt: new Date().toISOString(), nodes, sources, localHostId, checkIns: await readHostedCheckIns() };
}
function hostedCheckIn(member: RosterMember, inventory: RosterInventory): Row {
const unknown = (reason: string) => ({ status: "unknown", lastCheckInAt: null, expectedMinutes: member.check_in_minutes ?? null, reason });
// The daemon names its own host with its check-ins, so a team with no terminal seat still matches its members.
if (!inventory.checkIns) return unknown("this host's daemon gave no check-in status");
if (!inventory.checkIns.hostId || member.host !== inventory.checkIns.hostId) return unknown("it's listed on another host, whose check-ins this host doesn't hold");
const row = inventory.checkIns.members.find((m) => m.seat === member.seat);
return row && typeof row.status === "string" && row.status in CHECK_IN_DISPLAY ? row
: unknown("this host's daemon gave no status for it");
}
/** "overdue; last check-in 13:05; expected every 60 min", for the human roster view. */
export function formatCheckIn(checkIn: Row): string {
const status = typeof checkIn.status === "string" && checkIn.status in CHECK_IN_DISPLAY ? checkIn.status as CheckInStatus : "unknown";
const words = [CHECK_IN_DISPLAY[status] + (status === "unknown" && typeof checkIn.reason === "string" ? ` (${checkIn.reason})` : "")];
if (typeof checkIn.lastCheckInAt === "string") words.push(`last check-in ${checkIn.lastCheckInAt}`);
if (typeof checkIn.expectedMinutes === "number") words.push(`expected every ${checkIn.expectedMinutes} min`);
// "on request" already says it; for a member never seen, both facts stay visible.
else if (status === "no-check-in-yet" || status === "disconnected") words.push("no schedule declared");
return words.join("; ");
return { readAt: new Date().toISOString(), nodes, sources, localHostId };
}
export function enrichRosterMember(member: RosterMember, inventory: RosterInventory) {
@@ -135,9 +93,6 @@ export function enrichRosterMember(member: RosterMember, inventory: RosterInvent
// is in rig_team.
...(member.hosted ? { lastCallAt: inventory.localHostId && member.host === inventory.localHostId
? readPresence(mcpPresenceFilePath())[member.seat]?.lastCallAt ?? null : null } : {}),
// Whether it's checking its queue, for a member on this host; another host's member, or a status this daemon
// couldn't give, reads unknown.
...(member.hosted ? { checkIn: hostedCheckIn(member, inventory) } : {}),
} };
}
@@ -179,7 +134,7 @@ export function rosterCommand(deps: Deps = {}): Command {
else {
const members = (verb === "show" ? (result.roster as Row).members : result.matches) as Array<ReturnType<typeof enrichRosterMember> & { roster?: { id: string } }>;
if (verb === "show") { const r = result.roster as Roster; console.log(`${r.name} — ${r.purpose}\nCurator: ${r.curator.seat} (${r.curator.host})`); }
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}\n Check-in: ${formatCheckIn(m.live.checkIn ?? {})}` : ""}\n ${m.live.reason}`);
for (const m of members) console.log(`${m.seat} (${m.host})${m.roster ? ` [${m.roster.id}]` : ""}\n ${m.engagement.join(", ")}: ${m.capabilities.join(", ")}\n When: ${m.use_when}\n Why: ${m.why}${m.caveat ? `\n Caveat: ${m.caveat}` : ""}\n ${m.live.status}: runtime ${m.live.runtime ?? "unknown"}; configured model ${m.live.configuredModel ?? "unknown"}; last observed model ${formatModelObservation(m.live.modelObservation)}\n Activity: ${m.live.agentActivity.state}; sampled ${m.live.agentActivity.sampledAt ?? "unknown"}${m.hosted ? `\n Last call: ${m.live.lastCallAt ?? "unknown"}` : ""}\n ${m.live.reason}`);
if (!members.length) console.log("No matching members.");
const obs = result.observations as { sources: Row[] };
for (const source of obs.sources) if (source.status !== "ok") console.error(`Observation gap (${source.scope}): ${source.warning ?? source.error ?? source.status}`);
+1 -39
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { enrichRosterMember, formatCheckIn, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
import { enrichRosterMember, parseRoster, readRosters, rosterCommand, type RosterInventory } from "../src/commands/roster.js";
const member = { seat: "editor@studio", host: "host-a", capabilities: ["colour grading"], engagement: ["consult"], use_when: "Choose a look", why: "Knows the footage", caveat: "Advice only" };
const authored = { version: 1, id: "production", name: "Production", purpose: "Make a film", curator: { seat: "lead@studio", host: "host-a" }, updated_at: "2026-09-01", members: [member] };
@@ -133,41 +133,3 @@ it("does not choose the first duplicate route when activity observations conflic
expect(enrichRosterMember(member, { readAt: "2026-10-01T00:00:02Z", nodes: [idle, unclocked], sources: [] }).live.agentActivity)
.toEqual({ state: "idle", reason: null, sampledAt: null });
});
it("keeps any positive whole check-in cadence; one that can't be used still loads, with its reason and no cadence", () => {
const hourly = { ...member, seat: "bot@team", hosted: true, check_in_minutes: 60 };
expect(parseRoster({ ...authored, members: [hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }] }).members)
.toEqual([hourly, { ...hourly, seat: "fast@team", check_in_minutes: 1 }]);
for (const bad of [{ ...hourly, check_in_minutes: 0 }, { ...hourly, check_in_minutes: 1.5 }, { ...hourly, check_in_minutes: "60" },
{ ...member, check_in_minutes: 60 }]) {
const [loaded] = parseRoster({ ...authored, members: [bad] }).members;
expect(loaded).not.toHaveProperty("check_in_minutes");
expect(loaded!.check_in_problem).toMatch(/check_in_minutes/);
}
});
it("shows a hosted member's check-in status from this host's daemon; another host's member, or no answer, reads unknown", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const checkIns = [{ seat: "bot@team", rung: false, status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" }];
const here = { ...inventory([]), localHostId: "host-a", checkIns: { hostId: "host-a", members: checkIns } };
expect(enrichRosterMember(bot, here).live.checkIn).toMatchObject({ status: "check-in-overdue", lastCheckInAt: "2026-11-12T09:00:00.000Z" });
expect(formatCheckIn(enrichRosterMember(bot, here).live.checkIn!)).toBe("overdue; last check-in 2026-11-12T09:00:00.000Z; expected every 60 min");
for (const [missing, reason] of [[{ ...here, checkIns: null }, "this host's daemon gave no check-in status"],
[{ ...here, checkIns: { hostId: "host-b", members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: null, members: checkIns } }, "it's listed on another host, whose check-ins this host doesn't hold"],
[{ ...here, checkIns: { hostId: "host-a", members: [{ ...checkIns[0], status: "invented" }] } }, "this host's daemon gave no status for it"]] as const) {
expect(enrichRosterMember(bot, missing).live.checkIn).toEqual({ status: "unknown", lastCheckInAt: null, expectedMinutes: 60, reason });
}
expect(formatCheckIn({ status: "unknown", lastCheckInAt: null, expectedMinutes: null, reason: "its check-ins couldn't be read" }))
.toBe("unknown (its check-ins couldn't be read)");
// Never seen and no schedule declared: both facts stay visible.
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: null })).toBe("no check-in yet; no schedule declared");
expect(formatCheckIn({ status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 60 })).toBe("no check-in yet; expected every 60 min");
expect(formatCheckIn({ status: "on-request", lastCheckInAt: "2026-11-12T09:00:00.000Z", expectedMinutes: null })).toBe("on request; last check-in 2026-11-12T09:00:00.000Z");
expect(enrichRosterMember(member, here).live).not.toHaveProperty("checkIn");
});
it("a team with no terminal seat still shows its members' check-ins: the daemon names its own host with them", () => {
const bot = { ...member, seat: "bot@team", host: "host-a", hosted: true, check_in_minutes: 60 };
const fresh = { seat: "bot@team", rung: false, status: "checking-in", lastCheckInAt: "2026-11-12T09:55:00.000Z", expectedMinutes: 60, reporter: "openrig-observed" };
const noSeats: RosterInventory = { ...inventory([]), localHostId: null, checkIns: { hostId: "host-a", members: [fresh] } };
expect(enrichRosterMember(bot, noSeats).live.checkIn).toMatchObject({ status: "checking-in", lastCheckInAt: fresh.lastCheckInAt });
});
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig-plugins",
"repository": "github:mvschwarz/openrig-plugins",
"repository": "https://github.com/mvschwarz/openrig-plugins",
"license": "Apache-2.0",
"keywords": ["openrig", "agent", "topology", "rig", "continuity", "coordination", "compaction", "handoff"],
"skills": "./skills",
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"],
"skills": "./skills",
@@ -7,7 +7,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": [
"openrig",
@@ -8,7 +8,7 @@
"url": "https://github.com/mvschwarz"
},
"homepage": "https://github.com/mvschwarz/openrig",
"repository": "github:mvschwarz/openrig",
"repository": "https://github.com/mvschwarz/openrig",
"license": "Apache-2.0",
"keywords": ["openrig", "agents", "team", "delegation", "mcp"]
}
-4
View File
@@ -31,10 +31,6 @@
"types": "./dist/rosters-surface.d.ts",
"import": "./dist/rosters-surface.js"
},
"./connection-contract": {
"types": "./dist/connection-contract-surface.d.ts",
"import": "./dist/connection-contract-surface.js"
},
"./gateway-protocol": {
"types": "./dist/gateway-protocol-surface.d.ts",
"import": "./dist/gateway-protocol-surface.js"
@@ -1,3 +0,0 @@
// The `@openrig/daemon/connection-contract` surface: the shared outcome and status words, so the CLI and the daemon
// read them from one place.
export * from "./domain/connection-contract.js";
-2
View File
@@ -9,7 +9,6 @@ import { queueInboxCheckinsSchema } from "./migrations/158_queue_inbox_checkins.
import { queueItemsHandedOffFromIndexSchema } from "./migrations/159_queue_items_handed_off_from_index.js";
import { networkBindingsSchema } from "./migrations/160_network_bindings.js";
import { networkReceiptsSchema } from "./migrations/161_network_receipts.js";
import { sessionAddressReadIndexesSchema } from "./migrations/162_session_address_read_indexes.js";
import { activityGrantsSchema } from "./migrations/143_activity_grants.js";
import { environmentBindingsSchema } from "./migrations/144_environment_bindings.js";
import { nodePermissionsToSchema } from "./migrations/145_node_permissions_to.js";
@@ -326,5 +325,4 @@ export const ALL_MIGRATIONS: Migration[] = [
queueItemsHandedOffFromIndexSchema,
networkBindingsSchema,
networkReceiptsSchema,
sessionAddressReadIndexesSchema,
];
@@ -1,13 +0,0 @@
import type { Migration } from "../migrate.js";
/** Address reads seek a current binding; inventory recency is by id, not wall clock.
* Keep the older created_at index for readers that explicitly use that order. */
export const sessionAddressReadIndexesSchema: Migration = {
name: "162_session_address_read_indexes.sql",
sql: `
CREATE INDEX IF NOT EXISTS idx_bindings_tmux_session ON bindings(tmux_session)
WHERE tmux_session IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_node_latest_id ON sessions(node_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_name_latest_id ON sessions(session_name, id DESC);
`,
};
@@ -1,161 +0,0 @@
// The shared words for what happened to a piece of work and what a member can do. Today each part of OpenRig names
// these for itself (the transport, the outbox, the queue's wake record, the hosted ring, the watchdog), and the names
// drift: a result one reader doesn't recognise reads as fine to it and as a failure to another. This module is where
// the words live, so a consumer asks it instead of matching raw strings.
//
// It translates what is already stored; it never renames it. Activity stays in activity-taxonomy.ts, which this module
// doesn't redefine. An unrecognised word always reads `unknown`, never success and never failure.
/** The two contracts' identifiers, in the daemon's existing `<name>.v<N>` form (`operation-correlation.v1`). N changes
* only when a word's meaning changes incompatibly; new jobs and capabilities are advertised, not versioned. */
export const TEAM_OPERATIONS_CONTRACT = "team-operations.v1";
export const MEMBER_CONNECTION_CONTRACT = "member-connection.v1";
/** Whether a member or a daemon supports something. A client reads an absent answer as `unknown`, never as
* `not-supported`. */
export type Support = "supported" | "not-supported" | "unknown";
/** Who established a fact: OpenRig observing it directly, OpenRig's own adapter reporting what it read from the
* program, or an outside adapter. A member's own claim never makes a fact observed. */
export type Reporter = "openrig-observed" | "openrig-adapter" | "outside-adapter";
/** What a delivery route says happened to one attempt. None of them means the member read the work or did it: pickup
* is a claim on the row, and nothing else. */
export type DeliveryFact =
/** The route took it: text typed and submitted, a trigger answered 2xx. */
| "accepted"
/** Queued inside the program; no start seen yet. */
| "queued-not-started"
/** A turn start correlated to this attempt. */
| "turn-started"
/** Proven that nothing reached the member: no input was written, or the route refused before sending. */
| "not-delivered"
/** The route reported a failure after the attempt may already have reached the member (an error answer, a dropped
* connection): failed, with its effect unknown. */
| "failed-effect-unknown"
/** Held on purpose by a guard or a setting; nothing was written. */
| "held"
| "unknown";
export interface ReportedFact<F extends string> {
fact: F;
reporter: Reporter;
}
/** Whether a member outside any terminal is checking its queue. It's a status, never a refusal: work addressed to the
* member queues and is rung exactly as before, a check-in isn't pickup (pickup is a claim), and no check-in isn't
* idle, failure or safe. */
export type CheckInStatus =
/** Its last check-in is no older than twice its stated cadence (`CHECK_IN_OVERDUE_CADENCES`). */
| "checking-in"
/** Its last check-in is older than twice its stated cadence: its schedule may be off or waiting for an approval. */
| "check-in-overdue"
/** No check-in recorded for it. */
| "no-check-in-yet"
/** No cadence stated: it checks its queue when a person prompts it. */
| "on-request"
/** Its connection was revoked or removed, as recorded. Never inferred from silence. Nothing records a revocation yet,
* so nothing emits it: a revoked member stops checking in and reads overdue. */
| "disconnected"
/** Its check-ins couldn't be read, or its stated cadence can't be used (the reason says which). */
| "unknown";
/** How each check-in status reads to a person. */
export const CHECK_IN_DISPLAY: Readonly<Record<CheckInStatus, string>> = {
"checking-in": "checking in",
"check-in-overdue": "overdue",
"no-check-in-yet": "no check-in yet",
"on-request": "on request",
"disconnected": "disconnected",
"unknown": "unknown",
};
/** The one rule for a stated cadence: a last check-in no older than this many cadences reads checking in, and an older
* one overdue. One missed run, plus its schedule's own jitter, isn't overdue. A cadence is its person's statement, so
* neither reading proves a vendor schedule or a pickup. */
export const CHECK_IN_OVERDUE_CADENCES = 2;
export interface MemberCheckIn {
status: CheckInStatus;
/** Its own latest read of its pending inbox, as OpenRig observed it. */
lastCheckInAt: string | null;
/** The cadence its person stated at setup; null when none was. */
expectedMinutes: number | null;
reporter: Reporter;
/** Why the status is unknown, when it is. */
reason?: string;
}
/** A hosted member's check-in status from its stated cadence and its last observed check-in (`seen`, `never`, or
* `unknown` when the record can't be read). First match wins: disconnected, unknown, no check-in yet, on request,
* then checking in or overdue. Nothing passes `revokedAt` yet, so nothing reads disconnected. */
export function memberCheckInStatus(input: {
checkInMinutes: number | null | undefined;
/** Why the stated cadence can't be used, from the roster. */
cadenceProblem?: string | null;
checkIn: { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
revokedAt?: string | null;
now: Date;
}): MemberCheckIn {
const expectedMinutes = input.checkInMinutes ?? null;
const lastCheckInAt = input.checkIn.state === "seen" ? input.checkIn.at : null;
const unknown = (reason: string): MemberCheckIn => ({ status: "unknown", lastCheckInAt, expectedMinutes, reporter: "openrig-observed", reason });
const known = (status: CheckInStatus): MemberCheckIn => ({ status, lastCheckInAt, expectedMinutes, reporter: "openrig-observed" });
if (input.revokedAt) return known("disconnected");
if (input.cadenceProblem) return unknown(input.cadenceProblem);
if (input.checkIn.state === "unknown") return unknown("its check-ins couldn't be read");
if (input.checkIn.state === "never") return known("no-check-in-yet");
if (expectedMinutes === null) return known("on-request");
const age = input.now.getTime() - Date.parse(input.checkIn.at);
if (!Number.isFinite(age)) return unknown("its last check-in's time couldn't be read");
return known(age <= expectedMinutes * CHECK_IN_OVERDUE_CADENCES * 60_000 ? "checking-in" : "check-in-overdue");
}
/** The route a wake took. `none` means no route can carry it (a pull-only member, an address nothing holds). */
export type WakeRoute = "terminal" | "gateway" | "hosted-trigger" | "none";
/** One wake attempt's outcome, read from the queue's recorded nudge result. */
export interface WakeOutcome extends ReportedFact<DeliveryFact> {
/** Null when the recorded text names no route this reader knows. */
route: WakeRoute | null;
/** A terminal wake whose submission was seen rendered, not only sent. */
renderVerified?: true;
/** Which guard held it: the seat's hold or interruption level (retrying is held again), or the draft-aware check
* (a draft or unreadable input, which can clear on its own). */
heldBy?: "typing-guard" | "draft-aware";
/** The recorded text, unchanged. */
recorded: string;
}
/** Read a queue row's recorded nudge result (`last_nudge_result`) as a delivery fact. Every form a producer writes
* today is listed; anything else reads `unknown` with no route, so a consumer can tell "the wake's outcome can't be
* read" from every known outcome. Null when no attempt was recorded. */
export function readWakeOutcome(recorded: string | null | undefined): WakeOutcome | null {
if (recorded === null || recorded === undefined) return null;
const at = (fact: DeliveryFact, route: WakeRoute | null, extra: Partial<WakeOutcome> = {}): WakeOutcome =>
({ fact, reporter: "openrig-observed", route, recorded, ...extra });
if (recorded === "verified") return at("accepted", "terminal", { renderVerified: true });
// Sent and submitted without render verification: the route took it, nothing more.
if (recorded === "delivered-ack-pending") return at("accepted", "terminal");
if (recorded === "retained:typing_guard") return at("held", "terminal", { heldBy: "typing-guard" });
if (recorded.startsWith("retained:")) return at("held", "terminal", { heldBy: "draft-aware" });
// A hosted member's trigger answered: the ring was taken, not the work.
if (recorded.startsWith("wake-accepted:webhook")) return at("accepted", "hosted-trigger");
if (recorded.startsWith("indeterminate:webhook")) return at("unknown", "hosted-trigger");
// A ring that never left: no URL, no Authorization value, an invalid URL.
if (/^failed:webhook \([^)]*\) (isn't set:|not rung:)/.test(recorded)) return at("not-delivered", "hosted-trigger");
// Any other webhook failure came after the POST was sent. The trigger's own error answer is its report; a network
// error is what OpenRig saw. Either way the member may have the ring.
if (/^failed:webhook \([^)]*\) answered /.test(recorded)) {
return at("failed-effect-unknown", "hosted-trigger", { reporter: "outside-adapter" });
}
if (recorded.startsWith("failed:webhook")) return at("failed-effect-unknown", "hosted-trigger");
// The gateway's own ledger records whether the person was posted to; this attempt's own outcome is unknown.
if (recorded.startsWith("gateway-owned:")) return at("unknown", "gateway");
if (recorded.startsWith("unroutable:")) return at("not-delivered", "none");
// `indeterminate:` covers input that may have been written (a paste, an unconfirmed Enter) and timeouts.
if (recorded.startsWith("indeterminate:")) return at("unknown", "terminal");
// `failed:` is written only when no input could have reached the seat.
if (recorded.startsWith("failed:")) return at("not-delivered", "terminal");
return at("unknown", null);
}
+1 -31
View File
@@ -85,8 +85,7 @@ import { parseReplyToChoice, parseReplyToThreadMissing, formatReplyToChoice, des
import { classifyDestination, type DestinationClass } from "./gateway/destination-resolver.js";
import { join } from "node:path";
import { SettingsStore } from "./user-settings/settings-store.js";
import { hostedMembersAt, hostedWakeFor, isHostedMemberAt, type RosterMember, type RosterWake } from "./rosters.js";
import { memberCheckInStatus, type MemberCheckIn } from "./connection-contract.js";
import { hostedWakeFor, isHostedMemberAt, type RosterWake } from "./rosters.js";
import { hostedRingMayHaveArrived, ringHostedWake } from "./hosted-wake.js";
import { effectiveGateRoles } from "./gate-predicate.js";
import {
@@ -652,16 +651,6 @@ function defaultIsHostedMember(destination: string): boolean {
}
}
/** The hosted members the authored rosters list on this host. Empty when the rosters or this host's id can't be read. */
function defaultHostedMembers(): RosterMember[] {
try {
const root = new SettingsStore().resolveOne("workspace.root").value;
return typeof root === "string" && root ? hostedMembersAt(join(root, "rosters"), getSelfHostId()) : [];
} catch {
return [];
}
}
/** A session's latest read of its own pending inbox: when it was seen, never seen, or unknown (the record can't be read). */
export type InboxCheckIn = { state: "seen"; at: string } | { state: "never" } | { state: "unknown" };
@@ -1109,7 +1098,6 @@ export class QueueRepository {
private readonly hostedWakeFn: (destination: string) => RosterWake | null;
/** Whether the rosters list an address as a hosted member, trigger or not. */
private readonly hostedMemberFn: (destination: string) => boolean;
private readonly hostedMembersFn: () => RosterMember[];
private readonly permissionDecider?: (ask: QueueItem, requester: string) => { session: string; describe?: string } | null;
private readonly hostedWakeFetch: typeof fetch | undefined;
/** When this process last wrote each session's inbox check-in, so a seat that polls often writes at most once a minute. */
@@ -1163,7 +1151,6 @@ export class QueueRepository {
sharedChannels?: () => ReadonlyArray<{ address: string }>;
hostedWake?: (destination: string) => RosterWake | null;
isHostedMember?: (destination: string) => boolean;
hostedMembers?: () => RosterMember[];
hostedWakeFetch?: typeof fetch;
/** Who decides a runner's permission ask after its person answers, while the runner waits (startup wires the
* receiving-agent order, `permissionDeciderFor`). Absent, or null for an ask: the ask closes with the answer, as
@@ -1187,7 +1174,6 @@ export class QueueRepository {
this.sharedChannelsFn = opts?.sharedChannels ?? (() => sharedChannelAddresses());
this.hostedWakeFn = opts?.hostedWake ?? defaultHostedWake;
this.hostedMemberFn = opts?.isHostedMember ?? defaultIsHostedMember;
this.hostedMembersFn = opts?.hostedMembers ?? defaultHostedMembers;
this.permissionDecider = opts?.permissionDecider;
this.hostedWakeFetch = opts?.hostedWakeFetch;
this.hasTargetRepoColumn = detectQueueColumn(db, "target_repo");
@@ -5285,22 +5271,6 @@ export class QueueRepository {
}
}
/** Each hosted member on this host with its check-in status: its stated cadence against its own inbox reads. A status
* only; nothing here changes how work reaches it. */
hostedMemberCheckIns(now: Date = new Date()): Array<{ seat: string; rung: boolean } & MemberCheckIn> {
return this.hostedMembersFn().map((m) => ({ seat: m.seat, rung: m.wake !== undefined,
...memberCheckInStatus({ checkInMinutes: m.check_in_minutes, cadenceProblem: m.check_in_problem, checkIn: this.lastInboxCheckIn(m.seat), now }) }));
}
/** One member's check-in status, as `hostedMemberCheckIns` gives it. A seat the rosters don't list here (or can't be
* read for) states no cadence, so it reads by its check-ins alone. */
hostedMemberCheckIn(seat: string, now: Date = new Date()): MemberCheckIn {
let member: RosterMember | undefined;
try { member = this.hostedMembersFn().find((m) => this.isSameSession(m.seat, seat)); } catch { member = undefined; }
return memberCheckInStatus({ checkInMinutes: member?.check_in_minutes, cadenceProblem: member?.check_in_problem,
checkIn: this.lastInboxCheckIn(seat), now });
}
/** This session's latest recorded check-in under any of its names. */
lastInboxCheckIn(session: string): InboxCheckIn {
try {
+12 -28
View File
@@ -30,11 +30,10 @@ import { resolveOperatorSeat, resolveRecoveryAgentSeat, resolveSessionNodeId } f
import { nodeExecutorStoppedReason } from "./node-inventory.js";
import { isBoundTerminalSeat } from "./terminal-seat.js";
import type Database from "better-sqlite3";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSuccessor, matchesRecordedHandoffTarget, QueueRepositoryError, type InboxCheckIn, type QueueItem, type QueueRepository } from "./queue-repository.js";
import { stalledPickupFinding } from "./queue-pickup.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
import { readWakeOutcome, type MemberCheckIn } from "./connection-contract.js";
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
@@ -471,9 +470,8 @@ function deliveryResolutionEvidence(
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
if (row.state === "blocked") return "source parked";
if (kind === "undelivered-wake") {
const wake = readWakeOutcome(row.lastNudgeResult);
if (wake?.fact === "accepted" && wake.renderVerified) return "source wake verified";
if (wake?.fact === "accepted" && wake.route === "hosted-trigger") return "source wake accepted by its hosted trigger";
if (row.lastNudgeResult === "verified") return "source wake verified";
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
}
}
if (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
@@ -619,32 +617,18 @@ function unclaimedBacklogText(seat: string, rows: BacklogRow[], thresholdMinutes
* and what to do. Its work's age runs from each row's own time, which a check-in never resets. Built from the rows' fixed
* facts and the member's check-in state, never a current check-in's exact time, so a pass rewrites it only when the rows
* or that state change. */
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: MemberCheckIn,
function polledBacklogText(member: string, rows: BacklogRow[], thresholdMinutes: number, checkIn: InboxCheckIn,
now: Date): { summary: string; body: string } {
const { sorted, listed, count } = backlogListing(rows);
// The member's shared check-in status, worded. Only the words follow it; when this notice is filed doesn't. A member
// with no stated cadence keeps this threshold for whether it read its inbox lately, and no line carries a current
// check-in's time, so a check-in alone never rewrites the notice.
const at = checkIn.lastCheckInAt;
const cadence = checkIn.expectedMinutes;
const lately = at !== null && now.getTime() - Date.parse(at) <= thresholdMinutes * 60_000;
const recent = checkIn.status === "checking-in" || (checkIn.status === "on-request" && lately);
const checkIns = ((): string => {
switch (checkIn.status) {
case "checking-in": return `checking in: its last check-in is within twice its expected ${cadence} min`;
case "check-in-overdue":
return `overdue: no check-in since ${at}; expected every ${cadence} min, so its schedule may be off or waiting for an approval`;
case "on-request": return `on request (no schedule declared): ${lately
? `it read its own inbox within the last ${thresholdMinutes} min` : `no check-in since ${at}`}`;
case "no-check-in-yet":
return `no check-in yet; ${cadence !== null ? `expected every ${cadence} min` : "no schedule declared"}`;
case "disconnected": return "disconnected: its connection was revoked or removed";
case "unknown": return `unknown: ${checkIn.reason ?? "its check-in status couldn't be read"}`;
}
})();
const recent = checkIn.state === "seen" && now.getTime() - Date.parse(checkIn.at) <= thresholdMinutes * 60_000;
const checkIns = recent
? `checking in: it read its own inbox within the last ${thresholdMinutes} min`
: checkIn.state === "seen"
? `no check-in observed since ${checkIn.at}`
: checkIn.state === "never" ? "no check-in observed yet" : "unknown: its check-ins couldn't be read";
const why = recent
? "it's checking in, but this work is still unclaimed. Check whether it can do this work, or route it to another seat"
: checkIn.status === "unknown"
: checkIn.state === "unknown"
? "nobody has claimed these rows, and whether it's reading its queue isn't known. Ask its owner, or route the work to another seat"
: "nobody has claimed these rows, and no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat";
return {
@@ -1507,7 +1491,7 @@ export async function runStuckSweep(deps: StuckSweepDeps): Promise<StuckSweepRes
// A polling member's notice says whether it's checking in; an agent seat's names the route to add instead.
const polled = rows.length > 0 ? rows.some(polledUnclaimed) : pollsForWork(seat);
const backlogText = (standsFor: BacklogRow[]) => polled
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.hostedMemberCheckIn(seat, now), now)
? polledBacklogText(seat, standsFor, ageMinutes, deps.queueRepo.lastInboxCheckIn(seat), now)
: unclaimedBacklogText(seat, standsFor, ageMinutes, node);
// A polling member's notice goes where its rows' findings went: the person, else their creator when they share one,
// else the operator agent. With none, it stays with the member, which reads it when it next checks.
+24 -43
View File
@@ -12,7 +12,6 @@ import type { OperatorDeliveryEngine } from "./gateway/operator-delivery-engine.
import { openPromptRefusals, CLOSE_PREFIX, PARKED_OWNER_POLICY_NAME, REFUSED_PREFIX } from "./policies/parked-owner-consumer.js";
import { findQueueRecovery, recoveryTag } from "./queue-recovery.js";
import { lastMeaningfulTransition, type WaitingView } from "./queue-waiting.js";
import { readWakeOutcome } from "./connection-contract.js";
// S01 (OPR.0.5.5.1) — WAKE OR ESCALATE ON BATONS. A handoff whose wake fails must never
// silently park: today one failed nudge is recorded and nothing follows (the measured
// dominant 0.5.3 failure class — a perfect surviving packet, a recipient never woken).
@@ -384,44 +383,28 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
type WakeMode = "failed" | "unconfirmed" | "held";
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
// A claimed row's unconfirmed result is presented as `claimed` (presentedNudgeResult): the claim is pickup, which
// outranks any wake outcome.
if (lastNudgeResult === "claimed") return null;
const outcome = readWakeOutcome(lastNudgeResult);
if (!outcome) return null;
switch (outcome.fact) {
// Nothing reached the seat, so a retry can't double-deliver. An address no route can carry (`none`) has nothing
// to retry; its own notice covers it.
case "not-delivered":
return outcome.route === "none" ? null : "failed";
// A webhook failure after the POST: its effect is unknown, but it keeps the failed path and retry policy it has
// always had.
case "failed-effect-unknown":
return "failed";
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
// unclaimed baton keeps a retry path instead of stranding behind a terminal
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
// admitting retained there would make recovery OWN a row that nothing retries.
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
// records a fresh attempt; later retries reuse the same delivery id, whose
// retained readback returns `retained` again even after the guard has cleared —
// so the row re-probes once and then escalates rather than looping probes.
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
case "held":
return outcome.heldBy === "typing-guard" ? "held" : "failed";
// A rendered terminal wake, or a hosted trigger that took the ring (the stuck sweep owns a hosted member's
// unclaimed work), never enters. An unverified terminal send rides the confirmation path.
case "accepted":
return outcome.renderVerified || outcome.route === "hosted-trigger" ? null : "unconfirmed";
// Unknown, including a recorded result no reader recognises: it never retries (input may have arrived), and it
// escalates past the window when nothing picked the work up, instead of reading as delivered.
case "queued-not-started":
case "turn-started":
case "unknown":
return "unconfirmed";
}
if (!lastNudgeResult) return null;
if (lastNudgeResult.startsWith("failed:")) return "failed";
// #344 follow-up — a typing-guard refusal (retained:) wrote NO pane input, so an
// unclaimed baton keeps a retry path instead of stranding behind a terminal
// recovery. Admission is scoped to unclaimed batons by unclaimedWakeMode(): the
// claimed parked-owner arm's retry readback still selects `failed:%` only, so
// admitting retained there would make recovery OWN a row that nothing retries.
// Retry behaviour, as it actually runs: the first retry re-probes the guard and
// records a fresh attempt; later retries reuse the same delivery id, whose
// retained readback returns `retained` again even after the guard has cleared —
// so the row re-probes once and then escalates rather than looping probes.
// A draft-aware hold can clear on its own (the draft is sent or cleared), so it keeps that retry path. A hold or a
// level that holds the wake is the person's choice: retrying is held again, so it reads as held.
if (lastNudgeResult === "retained:typing_guard") return "held";
if (lastNudgeResult.startsWith("retained:")) return "failed";
if (
lastNudgeResult === "delivered-ack-pending" ||
lastNudgeResult.startsWith("indeterminate:") ||
lastNudgeResult.startsWith("gateway-owned:")
)
return "unconfirmed";
return null; // verified (or unknown vocabulary) — never enters the ladder
}
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
@@ -433,7 +416,7 @@ function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
function unclaimedWakeMode(row: Pick<QueueItem, "state" | "claimedAt" | "handedOffFrom" | "lastNudgeResult">): WakeMode | null {
const mode = classifyWakeResult(row.lastNudgeResult);
if (mode === null) return null;
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
const retained = row.lastNudgeResult?.startsWith("retained:") ?? false;
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
return mode;
}
@@ -880,9 +863,7 @@ export async function runWakeLadderTick(deps: WakeLadderDeps): Promise<WakeLadde
? `wake failed ${view.attempts} times over ${minutesSince(view.firstMarkerTs ?? row.tsCreated, now)} min`
: mode === "held"
? `held by ${row.destinationSession}'s typing guard for ${minutesSince(row.tsCreated, now)} min: nothing was typed, and it isn't retried`
: readWakeOutcome(row.lastNudgeResult)?.route === null
? `wake outcome unknown (unrecognised result "${row.lastNudgeResult!.slice(0, 80)}") with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
if (!due || suspended) continue;
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
+1 -25
View File
@@ -26,12 +26,6 @@ export interface RosterMember extends Reference {
hosted?: boolean;
/** A hosted member's trigger, rung when a row lands for it. */
wake?: RosterWake;
/** How often a hosted member is expected to check its queue, in minutes, as its person set it up (an hourly schedule
* is 60). Stated, not observed: absent means it checks only when a person prompts it. */
check_in_minutes?: number;
/** Why an authored `check_in_minutes` can't be used. The roster still loads; the member's check-in status reads
* unknown with this reason. */
check_in_problem?: string;
}
export interface Roster {
version: 1;
@@ -71,16 +65,9 @@ export function parseRoster(value: unknown): Roster {
|| (m.hosted !== undefined && typeof m.hosted !== "boolean")) {
throw new Error("Each member needs seat, host, capabilities, engagement, use_when and why (optional caveat, and hosted: true or false)");
}
// A cadence that can't be used never stops the roster loading: the member's check-in status reads unknown instead.
const cadence = m.check_in_minutes;
const cadenceProblem = cadence === undefined ? null
: m.hosted !== true ? "check_in_minutes is for a connected assistant (a member marked hosted: true)"
: !Number.isInteger(cadence) || (cadence as number) <= 0 ? `check_in_minutes ${JSON.stringify(cadence)} isn't a positive whole number of minutes`
: null;
return { seat: m.seat, host: m.host, capabilities: m.capabilities, engagement: m.engagement,
use_when: m.use_when as string, why: m.why as string, ...(m.caveat === undefined ? {} : { caveat: m.caveat as string }),
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }),
...(cadence === undefined ? {} : cadenceProblem ? { check_in_problem: cadenceProblem } : { check_in_minutes: cadence as number }) };
...(m.hosted === true ? { hosted: true } : {}), ...(m.wake === undefined ? {} : { wake: parseWake(m.wake) }) };
});
return { version: 1, id: value.id as string, name: value.name as string, purpose: value.purpose as string,
curator: { seat: value.curator.seat, host: value.curator.host }, updated_at: value.updated_at as string, members };
@@ -116,17 +103,6 @@ export function hostedWakeFor(destination: string, folder: string, selfHostId: s
return null;
}
/** The hosted members the rosters list on this host, one per seat: where two rosters list the same seat, the first by
* file name wins. Empty when this host's id is unknown. */
export function hostedMembersAt(folder: string, selfHostId: string | null): RosterMember[] {
if (!selfHostId) return [];
const bySeat = new Map<string, RosterMember>();
for (const roster of readRosters(folder).rosters) {
for (const m of roster.members) if (m.hosted === true && m.host === selfHostId && !bySeat.has(m.seat)) bySeat.set(m.seat, m);
}
return [...bySeat.values()];
}
/** Whether a roster on this host lists this address as a hosted member, with or without a trigger. */
export function isHostedMemberAt(destination: string, folder: string, selfHostId: string | null): boolean {
if (!selfHostId) return false;
+5 -16
View File
@@ -124,25 +124,14 @@ export interface CurrentSessionAddress {
* Physical names win over derived names. Archived/historical bindings cannot
* advertise a new alias, and two current matches never pick an arbitrary seat. */
export function resolveCurrentSessionAddress(db: Database.Database, address: string): CurrentSessionAddress | null {
const current = `SELECT r.name, r.archived_at, n.id AS nodeId, n.logical_id AS logicalId,
const rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
s.id AS sessionId, s.session_name AS sessionName, s.status,
replace(n.logical_id, '.', '-') || '@' || r.name AS senderAddress
FROM nodes n JOIN rigs r ON r.id=n.rig_id
JOIN bindings b ON b.node_id=n.id AND b.tmux_session IS NOT NULL
CROSS JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)`;
// Inventory passes physical names for every seat. The OR with a derived alias
// made each lookup walk all nodes (or all historical sessions). Seek the exact
// name first; only an absent active exact match needs the alias walk. Keep this
// in one statement so both alternatives observe the same database snapshot.
const rows = db.prepare(`WITH exact AS MATERIALIZED (
${current} WHERE b.tmux_session=? AND r.archived_at IS NULL
AND COALESCE(s.status, '') NOT IN ('superseded', 'detached', 'exited')
) SELECT * FROM exact
UNION ALL
${current}
WHERE replace(n.logical_id, '.', '-') || '@' || r.name=?
AND NOT EXISTS (SELECT 1 FROM exact)`)
JOIN sessions s ON s.node_id=n.id AND s.session_name=b.tmux_session
AND s.id=(SELECT id FROM sessions WHERE node_id=n.id ORDER BY id DESC LIMIT 1)
WHERE s.session_name=? OR replace(n.logical_id, '.', '-') || '@' || r.name=?`)
.all(address, address) as Array<CurrentSessionAddress & { name: string; archived_at?: string | null; status: string }>;
const active = rows.filter(row => row.archived_at == null && !['superseded', 'detached', 'exited'].includes(row.status));
const exact = active.filter(row => row.sessionName === address);
@@ -794,7 +783,7 @@ export class SessionRegistry {
`SELECT s.* FROM sessions s
JOIN nodes n ON s.node_id = n.id
WHERE n.rig_id = ?
ORDER BY s.created_at, s.rowid`
ORDER BY s.created_at`
)
.all(rigId) as SessionRow[];
@@ -282,7 +282,6 @@ function findCurrentClaudeQuestion(paneContent: string): string | null {
const CLAUDE_STATUS_WARNINGS = [
/^✘ Auto-update failed(?:: [^·\r\n]+)? · Run claude doctor$/,
/^✘ Auto-update failed · Try claude doctor or (?:npm i -g @anthropic-ai\/claude-code|cd ~\/\.claude\/local && npm update @anthropic-ai\/claude-code)$/,
/^✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai\/claude-c…$/,
/^tmux focus-events off · add 'set -g focus-events on' to ~\/\.tmux\.conf and re…$/,
/^You've used (?:\d|[1-9]\d)% of your weekly limit · resets \d{1,2}(?::\d{2})?(?:am|pm) \(UTC\)$/,
// #1641: Claude 2.1.293's update banner, whatever installer it names.
-5
View File
@@ -1441,11 +1441,6 @@ export function queueRoutes(): Hono {
return c.json(items);
});
// Each connected assistant on this host and whether it's checking its queue, with this daemon's own host id: the check-ins
// are this host's, so a reader matches members to it without needing a terminal seat to learn it. A status only:
// work addressed to an assistant that isn't checking in still queues and is rung as before. Precedes /:qitemId.
app.get("/hosted-members", (c) => c.json({ hostId: getSelfHostId(), members: getRepo(c).hostedMemberCheckIns() }));
// GET /undelivered — surfaces pending create-path nudge failures plus active
// human-notification episodes whose gateway delivery ledger failed or never posted.
// Rig-scoped + bounded + compact-by-default, mirroring /overdue.
@@ -89,23 +89,6 @@ describe("cursor-bound composer input", () => {
}
});
it.each(["", "person's draft"])("reads the observed clipped auto-update footer with input %j", body => {
// A complete synthetic frame; the retained partial capture cannot supply physical cursor rows.
const snapshot = claude(body);
snapshot.screen = snapshot.screen.replace("? for shortcuts", "⏵⏵ accept edits on (shift+tab to cycle)\n✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…");
expect(inspectComposerInput(snapshot, "claude-code").state).toBe(body ? "text" : "empty");
});
it("keeps near misses unknown below the footer and warning text inside a draft", () => {
const warning = "✘ Auto-update failed · Try claude doctor or npm i -g @anthropic-ai/claude-c…";
for (const nearMiss of [warning + " now", warning.replace("doctor", "doctors"), warning.replace("claude-c…", "other…")]) {
const snapshot = claude("");
snapshot.screen = snapshot.screen.replace("? for shortcuts", `? for shortcuts\n${nearMiss}`);
expect(inspectComposerInput(snapshot, "claude-code").state).toBe("unknown");
}
expect(inspectComposerInput(claude(warning, undefined, 100), "claude-code").state).toBe("text");
});
it("reads Claude's context hint below a named composer without skipping arbitrary rows", () => {
const frame = (body: string, styled = false, hint = "~66k uncached · /clear to start fresh"): ComposerSnapshot => ({
screen: [...Array<string>(22).fill(""),
@@ -1,94 +0,0 @@
import { describe, it, expect } from "vitest";
import { memberCheckInStatus, readWakeOutcome } from "../src/domain/connection-contract.js";
import { NUDGE_FAIL_PREFIX } from "../src/domain/policies/parked-owner-consumer.js";
describe("readWakeOutcome: every recorded nudge result reads as a delivery fact", () => {
// One row per form a producer writes today (queue-repository, hosted-wake, the destination resolver, the parked-owner
// consumer). A new producer form belongs here too, or it reads unknown.
const cases: Array<[string, Record<string, unknown>]> = [
["verified", { fact: "accepted", route: "terminal", renderVerified: true }],
["delivered-ack-pending", { fact: "accepted", route: "terminal" }],
["retained:typing_guard", { fact: "held", route: "terminal", heldBy: "typing-guard" }],
["retained:draft_aware", { fact: "held", route: "terminal", heldBy: "draft-aware" }],
["wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row", { fact: "accepted", route: "hosted-trigger" }],
["indeterminate:webhook (hook) didn't answer within 5000 ms", { fact: "unknown", route: "hosted-trigger" }],
// A webhook failure after the POST doesn't prove the member missed the ring; one before it does.
["failed:webhook (hook) answered 500", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
["failed:webhook (hook) answered 302, a redirect, which isn't followed", { fact: "failed-effect-unknown", route: "hosted-trigger", reporter: "outside-adapter" }],
["failed:webhook (hook) couldn't be reached (ECONNRESET)", { fact: "failed-effect-unknown", route: "hosted-trigger" }],
["failed:webhook (hook) isn't set: no webhook URL to ring", { fact: "not-delivered", route: "hosted-trigger" }],
["failed:webhook (hook) not rung: its Authorization value (auth) isn't set", { fact: "not-delivered", route: "hosted-trigger" }],
["failed:webhook (hook) not rung: the URL is invalid (bad scheme)", { fact: "not-delivered", route: "hosted-trigger" }],
["gateway-owned: 'slack:U1@external' is a virtual @external destination", { fact: "unknown", route: "gateway" }],
["unroutable: 'm@r' is a hosted member with no wake trigger", { fact: "not-delivered", route: "none" }],
["indeterminate:wake send timed out", { fact: "unknown", route: "terminal" }],
["indeterminate:earlier delivery unconfirmed; retry failed:x", { fact: "unknown", route: "terminal" }],
["indeterminate: parked-owner wake delivery — timed out", { fact: "unknown", route: "terminal" }],
["failed:tmux session not found", { fact: "not-delivered", route: "terminal" }],
["failed:terminal-seat: 'sh@r' is a terminal seat", { fact: "not-delivered", route: "terminal" }],
["failed:wake not retained: disk full", { fact: "not-delivered", route: "terminal" }],
[`${NUDGE_FAIL_PREFIX} — refused`, { fact: "not-delivered", route: "terminal" }],
];
it.each(cases)("%s", (recorded, expected) => {
const outcome = readWakeOutcome(recorded)!;
expect(outcome).toMatchObject({ reporter: "openrig-observed", ...expected, recorded });
});
it("only a rendered terminal wake is render-verified; an unverified send is accepted, nothing more", () => {
expect(readWakeOutcome("verified")?.renderVerified).toBe(true);
expect(readWakeOutcome("delivered-ack-pending")?.renderVerified).toBeUndefined();
});
it("an unrecognised result reads unknown with no route, never success or failure", () => {
for (const recorded of ["accepted-by-some-new-route", "claimed", "", "VERIFIED", "ok"]) {
expect(readWakeOutcome(recorded)).toEqual({ fact: "unknown", reporter: "openrig-observed", route: null, recorded });
}
});
it("no recorded attempt reads as no outcome", () => {
expect(readWakeOutcome(null)).toBeNull();
expect(readWakeOutcome(undefined)).toBeNull();
});
});
describe("memberCheckInStatus: a hosted member's check-in status", () => {
const now = new Date("2026-11-12T12:00:00.000Z");
const seenAgo = (minutes: number) => ({ state: "seen" as const, at: new Date(now.getTime() - minutes * 60_000).toISOString() });
const status = (checkInMinutes: number | null, checkIn: Parameters<typeof memberCheckInStatus>[0]["checkIn"], revokedAt?: string) =>
memberCheckInStatus({ checkInMinutes, checkIn, now, ...(revokedAt ? { revokedAt } : {}) }).status;
it("with a stated cadence, one rule: checking in up to exactly twice it, overdue a moment after", () => {
const at = (ms: number) => ({ state: "seen" as const, at: new Date(now.getTime() - ms).toISOString() });
expect(status(60, seenAgo(5))).toBe("checking-in");
expect(status(60, seenAgo(90))).toBe("checking-in"); // past one cadence is not yet overdue
expect(status(60, at(2 * 60 * 60_000))).toBe("checking-in");
expect(status(60, at(2 * 60 * 60_000 + 1))).toBe("check-in-overdue");
expect(status(1, at(2 * 60_000 + 1))).toBe("check-in-overdue"); // any positive cadence works the same way
});
it("a stated cadence that can't be used reads unknown with its reason, ahead of every other reading", () => {
expect(memberCheckInStatus({ checkInMinutes: null, cadenceProblem: "check_in_minutes \"60\" isn't a positive whole number of minutes",
checkIn: seenAgo(5), now })).toMatchObject({ status: "unknown", reason: expect.stringContaining("check_in_minutes") });
expect(memberCheckInStatus({ checkInMinutes: 60, checkIn: { state: "unknown" }, now })).toMatchObject({ status: "unknown", reason: "its check-ins couldn't be read" });
});
it("with no stated cadence it is on request, whatever its last check-in's age, and keeps that check-in", () => {
expect(status(null, seenAgo(5))).toBe("on-request");
expect(status(null, seenAgo(10_000))).toBe("on-request");
expect(memberCheckInStatus({ checkInMinutes: undefined, checkIn: seenAgo(5), now })).toMatchObject({ lastCheckInAt: seenAgo(5).at, expectedMinutes: null });
});
it("no recorded check-in reads never, with or without a cadence; an unreadable record reads unknown, never idle or overdue", () => {
expect(status(60, { state: "never" })).toBe("no-check-in-yet");
expect(memberCheckInStatus({ checkInMinutes: null, checkIn: { state: "never" }, now })).toMatchObject({ status: "no-check-in-yet", expectedMinutes: null });
expect(status(60, { state: "unknown" })).toBe("unknown");
expect(status(60, { state: "seen", at: "not a time" })).toBe("unknown");
});
it("disconnected comes only from a recorded revocation, ahead of everything else; silence never produces it", () => {
expect(status(60, seenAgo(5), "2026-11-12T11:00:00.000Z")).toBe("disconnected");
expect(status(60, seenAgo(100_000))).toBe("check-in-overdue");
expect(status(null, { state: "never" })).not.toBe("disconnected");
});
});
@@ -6,7 +6,6 @@ import Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
@@ -107,7 +106,7 @@ describe("the projector's execution queue", () => {
const NOW = new Date("2026-10-25T12:00:00.000Z");
function fixture(file?: string) {
const db = track(file ? createDb(file) : createDb());
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
return db;
}
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
+1 -2
View File
@@ -19,7 +19,6 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
@@ -168,7 +167,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
db = createDb();
migrate(db, [
coreSchema,
bindingsSessionsSchema, rigArchiveSchema,
bindingsSessionsSchema,
eventsSchema,
queueItemsSchema,
queueItemSummarySchema,
+1 -2
View File
@@ -108,7 +108,6 @@ import { humanQuestionsSchema } from "../../src/db/migrations/091_human_question
import { nodeEffortSchema } from "../../src/db/migrations/092_node_effort.js";
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
import { rigArchiveSchema } from "../../src/db/migrations/042_rig_archive.js";
import { sessionAddressReadIndexesSchema } from "../../src/db/migrations/162_session_address_read_indexes.js";
import { resumeProvenanceSchema } from "../../src/db/migrations/043_resume_provenance.js";
import { resumeVerificationSchema } from "../../src/db/migrations/045_resume_verification.js";
import { seatIdentityVerdictsSchema } from "../../src/db/migrations/046_seat_identity_verdicts.js";
@@ -169,7 +168,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema, sessionAddressReadIndexesSchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema, queueItemWakeInputBoxSchema, providerAccountReadingsSchema, humanQuietWindowsSchema, seatIdentityPanePidSchema, seatIdentityProcessEvidenceSchema, rigStartupLayersSchema, humanOperatingModesSchema, queueDelegatedDecisionSchema, nodeAdvisorSchema, operationCorrelationSchema, terminalPolicySchema, seatRuntimeSwitchesSchema, claudeTokenReadingsSchema, nodeLaunchRecipeSchema, nodeClaudeTokenRefSchema, nodeClaudePermissionsSchema, nodeCodexPermissionsSchema, escalationTargetRefsSchema, sessionAgentRunnerSchema, nodeAccountHomeSchema, nodeClaudeMcpSchema, activityGrantsSchema, environmentBindingsSchema, nodePermissionsToSchema, nodeStartupGuidanceFileSchema, managedBlockWritesSchema, streamIntakeCursorSchema, nodeLaunchWrapperSchema, piAccountSwitchesSchema, eventsRetentionMarksSchema, queueErasureSchema, queueErasureBatchesSchema, queueInboxCheckinsSchema, queueItemsHandedOffFromIndexSchema, networkBindingsSchema, networkReceiptsSchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
+3 -15
View File
@@ -16,7 +16,7 @@ import { NetworkBindingStore, networkCredentialFile } from "../src/domain/networ
import { Hono } from "hono";
import { connectMcpHub, HubError, type HubConnect, type HubItem, type HubSession } from "../src/domain/network/hub-client.js";
import { networkRoutes } from "../src/routes/network.js";
import { NetworkConnectorService, networkClosedElsewhere, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
import { NetworkConnectorService, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
const PARTICIPANT = "alex@team";
@@ -424,16 +424,6 @@ describe("network connector", () => {
expect(repo.getById(local!.qitemId)!.resolution).toBe("Here it is.");
});
it("closed-elsewhere conflicts are listed by when they settled, and ones that settled in the same millisecond by local item", () => {
const add = db.prepare(`INSERT INTO network_receipts (local_qitem_id, binding_id, hub_qitem_id, created_at, settled_at, outcome)
VALUES (?, 'netb-order', ?, '2026-11-11T19:00:00.000Z', ?, 'closed_on_hub')`);
// The latest has the first id, and the two that tie were recorded, and name hub items, out of local-item order.
add.run("qitem-net-a", "hub-1", "2026-11-11T19:00:00.001Z");
add.run("qitem-net-c", "hub-2", "2026-11-11T19:00:00.000Z");
add.run("qitem-net-b", "hub-3", "2026-11-11T19:00:00.000Z");
expect(networkClosedElsewhere(db, "netb-order").map((c) => c.localItem)).toEqual(["qitem-net-b", "qitem-net-c", "qitem-net-a"]);
});
it("a hub item already failed elsewhere gets no write and isn't our result, and a close that loses a race is read back as closed elsewhere", async () => {
const b = bind();
hub.add("hub-f1");
@@ -461,12 +451,10 @@ describe("network connector", () => {
// A later pass's ordinary contact doesn't clear it, and it's read from the receipts, so a restart keeps it.
expect((await pass(b.id)).error).toBeUndefined();
const listed = await (await app.request("/api/network/bindings")).json() as { bindings: Array<{ id: string; closedElsewhere: unknown[] }> };
// Listed in the order they settled, which can fall in one millisecond or two, so compared by local item.
const byLocal = (x: { localItem: string }, y: { localItem: string }) => x.localItem.localeCompare(y.localItem);
expect([...listed.bindings.find((x) => x.id === b.id)!.closedElsewhere as Array<{ localItem: string }>].sort(byLocal)).toEqual([
expect(listed.bindings.find((x) => x.id === b.id)!.closedElsewhere).toEqual([
{ hubItem: "hub-f1", localItem: f1!.qitemId, note: `The hub item was closed elsewhere (failed) before this result reached it. The result stays on ${f1!.qitemId}.` },
{ hubItem: "hub-f2", localItem: f2!.qitemId, note: `The hub item was closed elsewhere (done, no-follow-on) before this result reached it. The result stays on ${f2!.qitemId}.` },
].sort(byLocal));
].sort((x, y) => x.localItem.localeCompare(y.localItem)));
});
it("a close without a resolution sends only the closer's own closing note, never another note on the item", async () => {
@@ -90,7 +90,8 @@ describe("openrig-core plugin — manifest shape (HG-2.2)", () => {
// Hook + skills wiring
expect(manifest["skills"]).toBe("./skills");
expect(manifest["hooks"]).toBe("./hooks/claude.json");
expect(manifest["repository"]).toMatch(/github:mvschwarz\/openrig-plugins/);
// An https URL: Claude's plugin directory drops a `github:` shorthand from its listing.
expect(manifest["repository"]).toBe("https://github.com/mvschwarz/openrig-plugins");
expect(manifest["license"]).toBeDefined();
});
@@ -11,7 +11,7 @@ import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { QueueRepository, type QueueItem } from "../src/domain/queue-repository.js";
import { EventBus } from "../src/domain/event-bus.js";
import { queueRoutes } from "../src/routes/queue.js";
import type { RosterMember, RosterWake } from "../src/domain/rosters.js";
import type { RosterWake } from "../src/domain/rosters.js";
import { createStuckSweepStatus, runStuckSweep, STUCK_SWEEP_FINDING_TAG, unclaimedBacklogTag } from "../src/domain/queue-stuck-sweep.js";
const MEMBER = "helper-a@hub";
@@ -23,13 +23,12 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
let repo: QueueRepository;
function makeRepo(opts: { hostedWake?: (d: string) => RosterWake | null; resolveSessionAddresses?: (n: string) => string[];
validateRig?: (d: string) => boolean; hostedMembers?: () => RosterMember[] } = {}): QueueRepository {
validateRig?: (d: string) => boolean } = {}): QueueRepository {
return new QueueRepository(db, bus, {
validateRig: opts.validateRig ?? (() => true),
loadHumanRegistry: () => ({ ok: true, entities: [] }),
isHostedMember: (d) => d === MEMBER,
hostedWake: opts.hostedWake ?? (() => null),
hostedMembers: opts.hostedMembers ?? (() => []),
...(opts.resolveSessionAddresses ? { resolveSessionAddresses: opts.resolveSessionAddresses } : {}),
});
}
@@ -115,82 +114,16 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
expect(more).toEqual([]);
expect(notice!.destinationSession).toBe(PERSON);
expect(notice!.sourceSession).toBe(MEMBER);
expect(notice!.body).toContain(`check-ins: on request (no schedule declared): no check-in since ${at.toISOString()}`);
expect(notice!.body).toContain(`check-ins: no check-in observed since ${at.toISOString()}`);
expect(notice!.body).toContain("no read of its own inbox has been observed in that time. Ask its owner to check it's running, or route the work to another seat");
for (const row of rows) expect(notice!.body).toContain(`- ${row.qitemId}, unclaimed since ${row.tsCreated}, from lead@hub: work for ${MEMBER}`);
expect(notice!.body).not.toMatch(/dead/i);
});
/** The member as its roster lists it, with the cadence its person stated at setup (or none). */
const asListed = (checkInMinutes?: number): RosterMember[] => [{ seat: MEMBER, host: "hub", capabilities: ["research"],
engagement: ["delegate"], use_when: "research", why: "it researches", hosted: true,
...(checkInMinutes === undefined ? {} : { check_in_minutes: checkInMinutes }) }];
it("a stated cadence decides only the notice's check-in line, never when the notice is filed: overdue past twice it, checking in within it", async () => {
const at = new Date(Date.now() - 3 * 3_600_000);
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain(`check-ins: overdue: no check-in since ${at.toISOString()}; expected every 60 min, so its schedule may be off or waiting for an approval`);
// The same three-hour-old check-in is on time for a member expected every four hours, though older than the
// unclaimed age that judges a member with no stated cadence.
db.close();
db = new Database(":memory:");
migrate(db, ALL_MIGRATIONS);
bus = new EventBus(db);
repo = makeRepo({ hostedMembers: () => asListed(240) });
seedSeat("lead@hub");
repo.recordInboxCheckIn(MEMBER, at);
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: checking in: its last check-in is within twice its expected 240 min");
});
it("the notice says what the hosted-members read says: an unusable cadence reads unknown with its reason, even with a fresh check-in", async () => {
const member = { ...asListed()[0]!, check_in_problem: "check_in_minutes \"60\" isn't a positive whole number of minutes" };
repo = makeRepo({ hostedMembers: () => [member] });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 60_000));
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(repo.hostedMemberCheckIns()[0]).toMatchObject({ status: "unknown", reason: member.check_in_problem });
expect(notices()[0]!.body).toContain(`check-ins: unknown: ${member.check_in_problem}`);
expect(notices()[0]!.body).not.toContain("checking in");
});
it("check-in status is a status, never a refusal: work for an overdue or never-seen member queues and is recorded as before", async () => {
repo = makeRepo({ hostedMembers: () => asListed(60) });
repo.recordInboxCheckIn(MEMBER, new Date(Date.now() - 5 * 3_600_000));
const overdue = await repo.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
const unlisted = makeRepo();
const baseline = await unlisted.create({ sourceSession: "lead@hub", destinationSession: MEMBER, body: "work", summary: "work" });
expect(repo.getById(overdue.qitemId)).toMatchObject({ state: "pending", destinationSession: MEMBER });
expect(repo.getById(overdue.qitemId)!.lastNudgeResult).toBe(unlisted.getById(baseline.qitemId)!.lastNudgeResult);
expect(repo.hostedMemberCheckIns().map((m) => m.status)).toEqual(["check-in-overdue"]);
});
it("the hosted-members read gives each member's status, last check-in and stated cadence", async () => {
const members = [...asListed(60), { ...asListed()[0]!, seat: "helper-b@hub" }, { ...asListed(30)[0]!, seat: "helper-c@hub" }];
repo = makeRepo({ hostedMembers: () => members });
const at = new Date(Date.now() - 10 * 60_000);
repo.recordInboxCheckIn(MEMBER, at);
repo.recordInboxCheckIn("helper-b@hub", at);
const res = await appFor(repo).request("/api/queue/hosted-members");
expect(res.status).toBe(200);
const body = await res.json() as { hostId: unknown; members: unknown };
expect(body).toHaveProperty("hostId");
expect(body.members).toEqual([
{ seat: MEMBER, rung: false, status: "checking-in", lastCheckInAt: at.toISOString(), expectedMinutes: 60, reporter: "openrig-observed" },
{ seat: "helper-b@hub", rung: false, status: "on-request", lastCheckInAt: at.toISOString(), expectedMinutes: null, reporter: "openrig-observed" },
{ seat: "helper-c@hub", rung: false, status: "no-check-in-yet", lastCheckInAt: null, expectedMinutes: 30, reporter: "openrig-observed" },
]);
});
it("a member that never checked in says so", async () => {
await work(MEMBER, { ageMinutes: 61 });
await sweep();
expect(notices()[0]!.body).toContain("check-ins: no check-in yet; no schedule declared");
expect(notices()[0]!.body).toContain("check-ins: no check-in observed yet");
});
it("polling without claiming: old work still surfaces, and the member's check-ins neither reset its age nor rewrite the notice", async () => {
@@ -199,7 +132,7 @@ describe("stuck sweep: a hosted member that polls for its work", () => {
await poll();
await sweep();
const [notice] = notices();
expect(notice!.body).toContain("check-ins: on request (no schedule declared): it read its own inbox within the last 60 min");
expect(notice!.body).toContain("check-ins: checking in: it read its own inbox within the last 60 min");
expect(notice!.body).toContain("it's checking in, but this work is still unclaimed");
expect(notice!.body).toContain(`- ${old.qitemId}, unclaimed since ${old.tsCreated}`);
expect(notice!.body).not.toContain(young.qitemId);
@@ -275,56 +275,6 @@ describe("S01 wake-or-escalate — retry ladder, named rungs, derived suspension
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
});
it("UNKNOWN READS UNKNOWN: a baton whose recorded wake result no reader recognises escalates past the window, never re-sent, and the notice says the outcome is unknown", async () => {
const baton = await mkBaton();
setNudgeResult(baton.qitemId, "accepted-by-some-new-route", 45);
ageCreated(baton.qitemId, 45);
await tick();
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
const rungs = markersOf(baton.qitemId, LADDER_RUNG_PREFIX);
expect(rungs).toHaveLength(1);
expect(rungs[0]).toMatch(/wake outcome unknown \(unrecognised result "accepted-by-some-new-route"\)/);
expect(calls.some((c) => c.target === "worker@r")).toBe(false);
});
it("UNKNOWN READS UNKNOWN (negative controls): inside the window, or once claimed, an unrecognised result gets nothing", async () => {
const fresh = await mkBaton();
setNudgeResult(fresh.qitemId, "accepted-by-some-new-route", 5);
const claimed = await mkBaton("other@r");
setNudgeResult(claimed.qitemId, "accepted-by-some-new-route", 45);
ageCreated(claimed.qitemId, 45);
repo.claim({ qitemId: claimed.qitemId, destinationSession: "other@r" });
await tick();
expect(calls).toHaveLength(0);
expect(markersOf(fresh.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
expect(markersOf(claimed.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
});
it("KNOWN OUTCOMES KEEP THEIR PATH: a verified wake, a hosted trigger's accepted ring and an unroutable address never enter the ladder", async () => {
const batons = [];
for (const [idx, result] of ["verified", "wake-accepted:webhook (hook) answered 202; pickup is the member's claim on this row",
"unroutable: 'w2@r' has no terminal transport on this daemon"].entries()) {
const baton = await mkBaton(`w${idx}@r`);
setNudgeResult(baton.qitemId, result, 45);
ageCreated(baton.qitemId, 45);
batons.push(baton);
}
await tick();
expect(calls).toHaveLength(0);
for (const baton of batons) {
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(0);
expect(markersOf(baton.qitemId, LADDER_RUNG_PREFIX)).toHaveLength(0);
}
});
it("A WEBHOOK FAILURE AFTER THE POST KEEPS THE FAILED PATH: its effect is unknown, but it is retried as it always was", async () => {
const baton = await mkBaton();
setNudgeResult(baton.qitemId, "failed:webhook (hook) answered 500", 10);
await tick();
expect(markersOf(baton.qitemId, LADDER_ATTEMPT_PREFIX)).toHaveLength(1);
expect(calls).toEqual([{ qitemId: baton.qitemId, target: "worker@r" }]);
});
it("DELIVERY MACHINERY IS NOT PICKUP: a relay-owner-notification-transport-failed note on an unconfirmed or held baton does not count as pickup evidence", async () => {
const mod = await ladderMod();
for (const [idx, result] of ["gateway-owned: virtual destination", "retained:typing_guard"].entries()) {
+3 -4
View File
@@ -13,7 +13,6 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { streamItemsSchema } from "../src/db/migrations/023_stream_items.js";
@@ -71,7 +70,7 @@ describe("GET /api/review/*", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema, rigArchiveSchema,
bindingsSessionsSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -336,7 +335,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema, rigArchiveSchema,
bindingsSessionsSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -669,7 +668,7 @@ describe("qitem-ccf87c0d — composeMission MEMBERSHIP-scan load contract", () =
it("one composeMission over 40 indexed slices executes <= 4 membership queue scans AND composes all 40 ledger entries", () => {
const db = createDb();
migrate(db, [
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, streamItemsSchema,
coreSchema, bindingsSessionsSchema, eventsSchema, streamItemsSchema,
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
queueItemSummarySchema,
]);
@@ -1,86 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { ALL_MIGRATIONS } from "../src/db/all-migrations.js";
import { resolveCurrentSessionAddress, SessionRegistry } from "../src/domain/session-registry.js";
import { getNodeInventory } from "../src/domain/node-inventory.js";
const databases: Database.Database[] = [];
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
function fixture(indexed = true) {
const db = createDb(); databases.push(db);
migrate(db, ALL_MIGRATIONS.filter(m => indexed || m.name !== "162_session_address_read_indexes.sql"));
db.exec("INSERT INTO rigs(id,name) VALUES('r','team')");
const node = db.prepare("INSERT INTO nodes(id,rig_id,logical_id,runtime) VALUES(?,'r',?,'claude-code')");
const session = db.prepare("INSERT INTO sessions(id,node_id,session_name,status,created_at) VALUES(?,?,?,'running',?)");
const binding = db.prepare("INSERT INTO bindings(id,node_id,tmux_session,attachment_type) VALUES(?,?,?,'tmux')");
for (let n = 0; n < 4; n++) {
node.run(`n${n}`, `dev.worker${n}`);
for (let i = 0; i < 20; i++) session.run(`${n}-${String(i).padStart(3, '0')}`, `n${n}`, `native-${n}@team`, "2099-01-01");
// Session id defines recency even when the wall clock moves backwards.
session.run(`${n}-999`, `n${n}`, `native-${n}@team`, "2026-01-01");
binding.run(`b${n}`, `n${n}`, `native-${n}@team`);
}
return db;
}
describe("current session address read cost", () => {
it("seeks a physical name and the latest registration instead of scanning session history", () => {
const db = fixture(), sql: string[] = [], prepare = db.prepare;
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
try {
expect(resolveCurrentSessionAddress(db, "native-0@team")?.nodeId).toBe("n0");
expect(getNodeInventory(db, "r").map(n => n.canonicalSessionName)).toContain("native-0@team");
} finally { db.prepare = prepare; }
const address = sql.find(s => s.includes("AS senderAddress"))!;
const plan = db.prepare(`EXPLAIN QUERY PLAN ${address}`).all("native-0@team", "native-0@team") as { detail: string }[];
expect(plan.map(p => p.detail).join("\n")).toMatch(/SEARCH b USING INDEX idx_bindings_tmux_session/);
expect(plan.map(p => p.detail).join("\n")).not.toMatch(/SCAN s\b/);
const latest = db.prepare("EXPLAIN QUERY PLAN SELECT id FROM sessions WHERE node_id=? ORDER BY id DESC LIMIT 1").all("n0") as { detail: string }[];
expect(latest.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
});
it("keeps exact-name priority, derived aliases, and fresh binding changes", () => {
const db = fixture();
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.sessionName).toBe("native-0@team");
// A physical spelling takes priority over another seat's derived spelling.
db.prepare("UPDATE sessions SET session_name='dev-worker0@team' WHERE id='1-999'").run();
db.prepare("UPDATE bindings SET tmux_session='dev-worker0@team' WHERE node_id='n1'").run();
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")?.nodeId).toBe("n1");
db.prepare("UPDATE sessions SET status='exited' WHERE id='1-999'").run();
// That other seat's saved spelling still reserves the name.
expect(resolveCurrentSessionAddress(db, "dev-worker0@team")).toBeNull();
db.prepare("UPDATE rigs SET archived_at='2026-01-02' WHERE id='r'").run();
expect(resolveCurrentSessionAddress(db, "native-0@team")).toBeNull();
});
it("seeks the latest named registration when the generation reader resolves a session", () => {
const db = fixture(), sql: string[] = [], prepare = db.prepare;
db.prepare = function (text: string) { sql.push(text); return prepare.call(this, text); } as typeof db.prepare;
try { new SessionRegistry(db).currentOccupantGenerationForSession("native-0@team"); }
finally { db.prepare = prepare; }
const read = sql.find(s => s.includes("SELECT node_id FROM sessions WHERE session_name"))!;
expect(read).toBeDefined();
const plan = db.prepare(`EXPLAIN QUERY PLAN ${read}`).all("native-0@team") as { detail: string }[];
expect(plan.map(p => p.detail).join("\n")).toContain("SEARCH sessions USING INDEX idx_sessions_name_latest_id");
expect(plan.map(p => p.detail).join("\n")).not.toContain("TEMP B-TREE");
});
it("upgrades populated databases without changing registrations or inventory", () => {
const db = fixture(false);
const before = { inventory: getNodeInventory(db, "r"),
physical: resolveCurrentSessionAddress(db, "native-0@team"),
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() };
migrate(db, ALL_MIGRATIONS);
migrate(db, ALL_MIGRATIONS);
expect({ inventory: getNodeInventory(db, "r"),
physical: resolveCurrentSessionAddress(db, "native-0@team"),
alias: resolveCurrentSessionAddress(db, "dev-worker0@team"),
registrations: db.prepare("SELECT * FROM sessions ORDER BY id").all() }).toEqual(before);
expect(db.pragma("integrity_check", { simple: true })).toBe("ok");
expect(db.pragma("foreign_key_check")).toEqual([]);
});
});
@@ -370,23 +370,6 @@ describe("SessionRegistry", () => {
expect(names).toContain("r01-dev1-qa");
});
it("getSessionsForRig preserves insertion order for timestamp ties with the session indexes installed", () => {
const indexes = db.prepare("PRAGMA index_list(sessions)").all() as { name: string }[];
expect(indexes.map((index) => index.name)).toContain("idx_sessions_node_latest_id");
const insert = db.prepare("INSERT INTO sessions(id, node_id, session_name, created_at) VALUES (?, ?, ?, ?)");
// Non-monotonic ids distinguish insertion order from either id ordering.
insert.run("m-first", "node-1", "first@rig", "2026-01-02");
insert.run("z-second", "node-1", "second@rig", "2026-01-02");
insert.run("a-third", "node-1", "third@rig", "2026-01-02");
insert.run("n-fourth", "node-2", "fourth@rig", "2026-01-02");
// Timestamp remains the primary order even when this row was inserted last.
insert.run("earlier", "node-2", "earlier@rig", "2026-01-01");
expect(registry.getSessionsForRig("rig-1").map((session) => session.id)).toEqual([
"earlier", "m-first", "z-second", "a-third", "n-fourth",
]);
});
it("getBindingForNode returns null when unbound", () => {
const binding = registry.getBindingForNode("node-1");
expect(binding).toBeNull();
+1 -2
View File
@@ -7,7 +7,6 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
@@ -134,7 +133,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
db = createDb();
migrate(db, [
outboxEntriesSchema,
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
coreSchema, bindingsSessionsSchema, eventsSchema,
queueItemsSchema, queueTransitionsSchema,
queueItemSummarySchema, queueItemEvidenceRefSchema,
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
+1 -2
View File
@@ -7,7 +7,6 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
@@ -583,7 +582,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema, rigArchiveSchema,
bindingsSessionsSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
@@ -13,7 +13,6 @@ import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { rigArchiveSchema } from "../src/db/migrations/042_rig_archive.js";
import { outboxEntriesSchema } from "../src/db/migrations/027_outbox_entries.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
@@ -189,7 +188,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema, rigArchiveSchema,
bindingsSessionsSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
+30 -49
View File
@@ -48,8 +48,6 @@ interface ContentLine {
zones?: Array<{ start: number; end: number; action: Action }>;
/** slice-17: token segments for canvas-rendered rows (graph view) */
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
sticky?: { group: string; text?: string };
}
function pad(text: string | number | null | undefined, width: number): string {
@@ -96,24 +94,17 @@ function agentColumns(contentWidth: number): AgentColumn[] {
];
}
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
if (contentWidth >= 110) {
const rig = Math.min(Math.max(longestRigName, 13), 24);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
];
}
if (contentWidth >= 78) {
const rig = Math.min(Math.max(longestRigName, 10), 16);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
];
}
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
if (contentWidth >= 110) return [
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
];
if (contentWidth >= 78) return [
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
];
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
return [
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
@@ -718,7 +709,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
: ` (no rigs served by ${host.name})`;
}
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
function instanceContentLines(
state: ViewState,
snap: FleetSnapshot,
@@ -810,12 +801,7 @@ function instanceContentLines(
lines.push(healthSummaryLine(snap, scope, contentWidth));
lines.push(...queueSampleLines(snap, contentWidth));
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
const shownRigs = host.rigs.map((rig) => ({
rig,
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
const columns = instanceAgentColumns(contentWidth);
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
lines.push({ text: "━".repeat(columnsWidth(columns)) });
let seatCount = 0;
@@ -824,7 +810,10 @@ function instanceContentLines(
let attentionUnknown = 0;
let openCount = 0;
let firstRig = true;
for (const { rig, agents } of shownRigs) {
for (const rig of host.rigs) {
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
firstRig = false;
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
@@ -836,7 +825,7 @@ function instanceContentLines(
continue;
}
let previousPod: string | null = null;
for (const [index, { pod, agent }] of agents.entries()) {
for (const { pod, agent } of agents) {
const firstInPod = pod !== previousPod;
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
previousPod = pod;
@@ -847,21 +836,18 @@ function instanceContentLines(
if (agent.needsAttention === true) attentionCount += 1;
else if (agent.needsAttention == null) attentionUnknown += 1;
openCount += queue.count;
const cells = {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
};
const named = tableRow(columns, cells);
lines.push({
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
text: tableRow(columns, {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
}),
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
});
@@ -1958,12 +1944,7 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
const maxContentOffset = Math.max(content.length - contentRows, 0);
const contentStart = Math.min(state.contentOffset, maxContentOffset);
const labelledGroups = new Set<string>();
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
labelledGroups.add(line.sticky.group);
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
});
const visibleContent = content.slice(contentStart, contentStart + contentRows);
if (content.length > bodyRows) {
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
const up = scrollText.indexOf("↑");
@@ -6,7 +6,6 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
import { renderScreen } from "../src/render.js";
import { computeExplorerRows, createViewState } from "../src/state.js";
import { createStyle, stripAnsi } from "../src/theme.js";
import { explorerWidth } from "../src/visual-layout.js";
import { stylizeLines } from "../src/stylize.js";
import type { AgentRow, FleetSnapshot } from "../src/types.js";
@@ -119,20 +118,16 @@ describe("S12 instance mission control", () => {
let scrolledSeatRows = 0;
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
const namedRigs = new Set<string>();
for (const target of screen.contentTargets) {
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
reached.add(target.action.name);
const rig = target.action.target?.rig;
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
const firstVisible = !!rig && !namedRigs.has(rig);
if (rig) namedRigs.add(rig);
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
if (headingVisible) continue;
scrolledSeatRows += 1;
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
@@ -145,7 +140,7 @@ describe("S12 instance mission control", () => {
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
expect(whole).not.toMatch(/\+\d+|more hidden/i);
expect(reached).toEqual(expectedAgents);
@@ -324,138 +319,3 @@ describe("S12 view-aware hydration", () => {
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
});
});
describe("instance table RIG column (#2848)", () => {
const LONG = "RIG-session-review";
const OVER_CAP = "rig-name-thirty-characters-xx";
function colsFor(contentWidth: number): number {
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
}
throw new Error(`no terminal width gives content width ${contentWidth}`);
}
function snapshotWithRigs(names: string[]): FleetSnapshot {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs.push(...names.map((name) => ({
id: `r-${name}`,
name,
lifecycleState: "running",
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
})));
return snap;
}
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
const view = openInstance(snap);
if (filter) view.dispatch({ type: "filter", text: filter });
const cols = colsFor(contentWidth);
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
expect(header, `${contentWidth}: table header`).toBeDefined();
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
const rigWidth = header!.indexOf("POD") - 1;
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
}
it.each([
// [content width, expected RIG width, flexible column, expected flexible width]
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
[78, 16, "WORK", 78 - 54 - (16 - 10)],
[109, 16, "WORK", 109 - 54 - (16 - 10)],
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
expect(actual).toBe(rigWidth);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(header.trimEnd().endsWith(flexible)).toBe(true);
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
});
it("keeps the old RIG width as the minimum when every name is short", () => {
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
});
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
expect(rigWidth).toBe(cap);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
});
function seatRows(screen: ReturnType<typeof renderScreen>) {
return screen.contentTargets
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
.map((target) => ({
y: target.y,
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
name: target.action.type === "drill" ? target.action.name : "",
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
}));
}
it("names every rig on its single row when each rig has one seat", () => {
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
const { screen } = table(snapshotWithRigs(names), 160);
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
expect(rows.map((row) => row.rig)).toEqual(names);
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
});
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
const build = seatRows(screen).filter((row) => row.rig === "build");
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
});
it("drills into the row's rig from a blank RIG cell", () => {
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
}
const pastRig = screen.explorerWidth + 3 + rigWidth;
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
});
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
));
const view = openInstance(snap);
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
let checked = false;
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
const build = seatRows(screen).filter((row) => row.rig === "build");
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
expect(build[0]!.text.startsWith("build ")).toBe(true);
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
checked = true;
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
expect(checked).toBe(true);
});
it("sizes RIG from the rigs the filter leaves on screen", () => {
const snap = snapshotWithRigs([LONG, "short-rig"]);
expect(table(snap, 160).rigWidth).toBe(LONG.length);
expect(table(snap, 160, "short").rigWidth).toBe(13);
});
});