Compare commits

...
Author SHA1 Message Date
dev60-review 529b24b0a8 docs(getting-started): start one stopped kernel seat with rig launch, and say what --fresh loses
The guide said a kernel with only some seats stopped starts again with
`rig up kernel --existing`. That refuses while any kernel seat is live,
which is exactly that case. Name the command that does it,
`rig launch kernel <logical id>`, which restores the one seat from the
kernel's latest restore-usable snapshot and resumes its saved
conversation, and say that `rig seat launch <seat> --fresh` starts a new
conversation with no continuity, for when a fresh occupant is wanted.
2026-11-12 03:17:37 +00:00
Coder8124 492170b1b5 fix(daemon): give an unreadable Claude statusline cache its own reason (#2880)
`rig provider status` gave a Claude statusline cache that couldn't be parsed the same unknown reason as a valid cache with no rate-limit windows, so a usage check couldn't tell a broken cache from one with nothing reported yet. An unreadable cache now reports `claude_statusline_cache_unreadable`, and a readable cache with no windows keeps `claude_statusline_cache_had_no_windows`; both stay unknown rows with no percentage. Discovery also keeps a seat whose only cache file is malformed, naming it from the file name, so that seat reads unreadable instead of no cache yet. For #2875.
2026-11-11 18:51:26 -08:00
Mike Schwarz bf31a9229d fix: recognize the clipped Claude auto-update footer (#3016)
When a Claude seat's pane is narrow, the auto-update footer is clipped, and the clipped form didn't match the shared footer patterns, so draft-aware delivery could hold a send behind it. The clipped footer is now recognised by one exact anchored pattern, matched against the banked frame. Frames cropped some other way remain a stated limit. This follows #2948 for #2925.
2026-11-11 18:33:06 -08:00
Mike Schwarz 9e58308c0c docs(changelog): after rig unclaim, end the process and any tmux session, not its viewing window (#3014)
The 0.7.12 CHANGELOG entry for an adopted session said to close the viewing window after rig unclaim, which leaves the tmux session running. It now says to end the process and any tmux session, and the tmux part applies only where there is one. CHANGELOG text only, matching the route merged in #3006.
2026-11-11 18:32:05 -08:00
Mike Schwarz 8df4672263 fix(daemon): read an unrecognised wake result as unknown, not delivered (#3011)
The wake ladder read a wake result it didn't recognise as delivered, so a seat could be marked woken when nothing confirmed it. An unrecognised result now reads unknown. A webhook failure after the request was sent reads failed-effect-unknown with the reporter named, while a failure before sending stays not-delivered, and ladder routing and retries are unchanged. It's the first adopter of the shared outcome words.
2026-11-11 18:31:34 -08:00
Mike Schwarz eb6a218010 test(network): compare closed-elsewhere conflicts without depending on settle order (#3015)
A network connector test compared closed-elsewhere conflicts in the order they settled, which isn't fixed, so the daemon suite failed intermittently on unrelated PRs. The cell now compares them without depending on settle order, and a new cell pins the tie order. Test only; the product read order was already total.
2026-11-11 18:31:05 -08:00
Mike Schwarz 559025c090 perf(daemon): index current-session reads for node inventory (#2997)
Node inventory resolved each seat's current session by scanning its session history, so the read grew with history on a long-lived daemon, one of the parent daemon's node-route stall paths. Migration 162 adds two indexes for the current-session reads, and the session registry's queries now seek on them, resolving equal timestamps by the current database's rowid. Read plans are pinned in a new test. The history-scaling repair is measured; the production pause attribution stays open.
2026-11-11 17:55:10 -08:00
hgreene624 37e0715c58 fix(tui): group instance rows by rig and fit the RIG column (#2857)
The TUI's instance table cut long rig names to a fixed 13 cells and drew no break between rigs, so rigs that share a prefix looked alike and a new rig's first row read as part of the last pod. The table now shows the rig name once per rig group, on its first row, as the POD column does, and keeps it on the top visible row when scrolled. The RIG column is sized to the longest rig name shown, from the old width up to a cap, with NOW or WORK giving up the difference so the row width is unchanged. A blank RIG cell still drills into its rig. For #2848.
2026-11-11 17:36:19 -08:00
31 changed files with 742 additions and 91 deletions

No files matched your search

+3 -1
View File
@@ -152,7 +152,9 @@ 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 close it in its own terminal. ([#2962](https://github.com/mvschwarz/openrig/pull/2962))
(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))
- **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,7 +140,11 @@ 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.
`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.
- `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
+3 -1
View File
@@ -13,7 +13,9 @@ provider usage stop updating, and Claude resume-token capture at adoption or
handover loses its current session evidence. An earlier sidecar can remain and
contain an older session ID; without one, context and capture report
`missing_sidecar`, and provider usage reports `no_statusline_cache_yet` unless an
earlier cache remains.
earlier cache remains. A cache that exists but cannot be read reports
`claude_statusline_cache_unreadable`; a readable cache that carried no rate
limits reports `claude_statusline_cache_had_no_windows`.
## Feed both commands the same input
+10 -3
View File
@@ -342,9 +342,16 @@ startup reconcile leaves when every kernel terminal is gone, for example after a
reboot. It does not detect a reboot as such. It uses the same restore as
`rig up kernel --existing`, and `rig status` reports it as `Kernel: booting`
until the seats are ready. A kernel stopped with `rig down kernel`, including
one whose seats were already detached, stays down on later starts. So do a kernel
with only some seats stopped and seats you unclaimed. Start one again with
`rig up kernel --existing`.
one whose seats were already detached, stays down on later starts; start it again
with `rig up kernel --existing`. A seat you stopped or unclaimed also stays down.
While the kernel's other seats are running, `rig up kernel --existing` refuses
(it restores only a kernel with no live session), so start one stopped seat with
`rig launch kernel <logical id>` (`--plan` first). It restores that seat from the
kernel's latest restore-usable snapshot and resumes its saved conversation; a seat
it can't resume is reported as waiting for a decision, not as started.
`rig seat launch <seat> --fresh --reason "…"` instead starts a new conversation
with no continuity source: use it only when you want a fresh occupant, since the
seat's earlier conversation isn't carried.
When the tmux server is gone, an explicit `rig down` of any rig can start an
empty server and probe again. It records the stop and clears eligible bindings
only after confirming the sessions are absent. This can leave an empty tmux
+2
View File
@@ -9,6 +9,7 @@ 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";
@@ -325,4 +326,5 @@ export const ALL_MIGRATIONS: Migration[] = [
queueItemsHandedOffFromIndexSchema,
networkBindingsSchema,
networkReceiptsSchema,
sessionAddressReadIndexesSchema,
];
@@ -0,0 +1,13 @@
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);
`,
};
@@ -0,0 +1,92 @@
// 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;
}
/** 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);
}
@@ -61,15 +61,16 @@ export interface ClaudeUsageReaderDeps {
/**
* Read each Claude seat's provider_usage cache → provider_statusline rows via
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet) and
* cache-present-but-no/-malformed windows (empty_reading).
* Never throws — a malformed cache is treated as present-with-no-windows, not a crash.
* claudeStatuslineSignals. Explicit unknown rows for absent cache (no_statusline_cache_yet),
* an unreadable cache (unreadable_cache), and a valid cache with no windows (empty_reading).
* Never throws — a malformed cache becomes an unknown row, not a crash.
*/
export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): ProviderSignal[] {
const now = deps.now();
const out: ProviderSignal[] = [];
for (const seat of deps.listClaudeSeats()) {
let cachePresent = false;
let cacheUnreadable = false;
let reading: ClaudeStatuslineReading | undefined;
let staleAfter: string | undefined;
let capturedAsOf: string | undefined;
@@ -84,7 +85,8 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
staleAfter = parsed.staleAfter;
capturedAsOf = typeof parsed.asOf === "string" ? parsed.asOf : undefined;
} catch {
// Malformed cache → present-with-no-windows (empty_reading unknown), never a throw.
// Malformed cache → present-but-unreadable (its own unknown), never a throw.
cacheUnreadable = true;
reading = undefined;
}
}
@@ -92,6 +94,7 @@ export function collectClaudeStatuslineSignals(deps: ClaudeUsageReaderDeps): Pro
...claudeStatuslineSignals({
seatSession: seat.seatSession,
cachePresent,
cacheUnreadable,
reading,
// Signal asOf = when the reading was captured (the cache's asOf) when present+valid; else now.
asOf: cachePresent && capturedAsOf ? capturedAsOf : now,
@@ -109,6 +112,7 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
legacyDirectory?: string,
): ProviderSignal[] {
const seats = new Map<string, ClaudeSeatRef>();
const unreadableFiles = new Set<string>();
for (const candidateDirectory of [directory, legacyDirectory]) {
if (!candidateDirectory) continue;
try {
@@ -119,10 +123,16 @@ export function collectClaudeSignalsFromProviderUsageDirectory(
if (typeof parsed.seatSession === "string") {
seats.set(parsed.seatSession, { seatSession: parsed.seatSession });
}
} catch { /* malformed cache cannot create a usable cache signal */ }
} catch {
// A cache file that exists but cannot be read or parsed still names its seat in the
// filename (telemetrySidecarFilename sanitizes the same seat name). Keep it in the
// seat list so the reader reports an unreadable cache instead of the file vanishing.
unreadableFiles.add(file.slice(0, -".json".length));
}
}
} catch { /* absent cache directory */ }
}
for (const seatSession of unreadableFiles) seats.set(seatSession, { seatSession });
const readFrom = (candidateDirectory: string, seatSession: string): string | null => {
try {
@@ -131,6 +131,8 @@ export interface ClaudeSignalInput {
seatSession: string;
/** false = the statusline cache has not been written yet (before the first API response). */
cachePresent: boolean;
/** True when a cache file exists but cannot be read or parsed. Distinct from an empty reading. */
cacheUnreadable?: boolean;
/** Present only when the cache exists and carried a rate_limits object. */
reading?: ClaudeStatuslineReading;
asOf: string;
@@ -140,6 +142,7 @@ export interface ClaudeSignalInput {
export const CLAUDE_UNKNOWN_REASON = {
no_statusline_cache_yet: "claude_no_statusline_cache_yet",
empty_reading: "claude_statusline_cache_had_no_windows",
unreadable_cache: "claude_statusline_cache_unreadable",
} as const;
function claudeUnknownSignal(
@@ -165,17 +168,22 @@ function claudeUnknownSignal(
/**
* Normalize a Claude statusline `rate_limits` reading into `signals[]` rows.
* - seat before first response (no cache) → explicit unknown row.
* - a cache that exists but cannot be read/parsed → its own unknown reason (broken, not empty).
* - seat with subscription windows → one `provider_statusline` row per window (five_hour, and
* seven_day normalized to "weekly"); a genuine 0 survives as 0.
* - subscription with cache but no windows → explicit unknown row (never fabricated zeros).
*/
export function claudeStatuslineSignals(input: ClaudeSignalInput): ProviderSignal[] {
const { seatSession, cachePresent, reading, asOf, staleAfter } = input;
const { seatSession, cachePresent, cacheUnreadable, reading, asOf, staleAfter } = input;
if (!cachePresent) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.no_statusline_cache_yet, staleAfter)];
}
if (cacheUnreadable) {
return [claudeUnknownSignal(seatSession, asOf, CLAUDE_UNKNOWN_REASON.unreadable_cache, staleAfter)];
}
const windows: Array<["five_hour" | "weekly", ClaudeStatuslineWindow | undefined]> = [
["five_hour", reading?.five_hour],
["weekly", reading?.seven_day],
@@ -34,6 +34,7 @@ import { createLinkedSuccessorLookup, deriveCrossHostSuccessorId, hasLinkedSucce
import { stalledPickupFinding } from "./queue-pickup.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { loadHostRegistry } from "./hosts/hosts-registry-reader.js";
import { readWakeOutcome } from "./connection-contract.js";
export const STUCK_SWEEP_INTERVAL_KEY = "queue.stuck_sweep_interval_seconds";
export const DEFAULT_STUCK_SWEEP_INTERVAL_SECONDS = 300;
@@ -470,8 +471,9 @@ function deliveryResolutionEvidence(
if (row.claimedAt) return `source claimed at ${row.claimedAt}`;
if (row.state === "blocked") return "source parked";
if (kind === "undelivered-wake") {
if (row.lastNudgeResult === "verified") return "source wake verified";
if (row.lastNudgeResult?.startsWith("wake-accepted:")) return "source wake accepted by its hosted trigger";
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 (deps.queueRepo.humanNotificationPostedThisEpisode(qitemId)) return "current human ask posted";
+43 -24
View File
@@ -12,6 +12,7 @@ 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).
@@ -383,28 +384,44 @@ function hasPickupEvidence(db: Database.Database, row: Pick<QueueItem, "qitemId"
type WakeMode = "failed" | "unconfirmed" | "held";
function classifyWakeResult(lastNudgeResult: string | null): WakeMode | null {
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 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";
}
}
/** A retained (typing-guard) result is retryable ONLY for an unclaimed baton. The
@@ -416,7 +433,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 = row.lastNudgeResult?.startsWith("retained:") ?? false;
const retained = readWakeOutcome(row.lastNudgeResult)?.fact === "held";
if (retained && !(row.state === "pending" && !row.claimedAt && row.handedOffFrom)) return null;
return mode;
}
@@ -863,7 +880,9 @@ 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`
: `unconfirmed delivery with no pickup evidence over ${minutesSince(row.tsCreated, now)} min`;
: 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`;
if (hasConsumedEscalationHop(deps.db, row.qitemId)) {
if (!due || suspended) continue;
const consumedReason = `${reason}; escalates_to one-hop delivery already used; existing fallback retained`;
+16 -5
View File
@@ -124,14 +124,25 @@ 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 rows = db.prepare(`SELECT r.*, n.id AS nodeId, n.logical_id AS logicalId,
const current = `SELECT r.name, r.archived_at, 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
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=?`)
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)`)
.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);
@@ -783,7 +794,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`
ORDER BY s.created_at, s.rowid`
)
.all(rigId) as SessionRow[];
@@ -282,6 +282,7 @@ 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.
@@ -89,6 +89,23 @@ 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(""),
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { 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();
});
});
@@ -6,6 +6,7 @@ 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";
@@ -106,7 +107,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, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
migrate(db, [coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, queueItemSummarySchema, queueItemsSchema, queueTransitionsSchema, queueTransitionWakesSchema, viewsCustomSchema]);
return db;
}
const insertRow = (db: Database.Database, id: string, state: string, tags: string[], body: string, claimedAt: string | null = "2026-10-25T11:00:00.000Z") =>
+2 -1
View File
@@ -19,6 +19,7 @@ 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";
@@ -167,7 +168,7 @@ describe("execution view — S27 (OPR.0.5.6.27)", () => {
db = createDb();
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueItemSummarySchema,
+2 -1
View File
@@ -108,6 +108,7 @@ 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";
@@ -168,7 +169,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];
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];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
+15 -3
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, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
import { NetworkConnectorService, networkClosedElsewhere, networkLocalItemId, runNetworkBindingPass, type NetworkConnectorDeps } from "../src/domain/network/network-connector.js";
const PARTICIPANT = "alex@team";
@@ -424,6 +424,16 @@ 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");
@@ -451,10 +461,12 @@ 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[] }> };
expect(listed.bindings.find((x) => x.id === b.id)!.closedElsewhere).toEqual([
// 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([
{ 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((x, y) => x.localItem.localeCompare(y.localItem)));
].sort(byLocal));
});
it("a close without a resolution sends only the closer's own closing note, never another note on the item", async () => {
@@ -112,6 +112,40 @@ describe("Slice-04 C3 — seat-keyed Claude provider_usage discovery (production
}
});
it("a live seat whose only cache is malformed reports unreadable_cache through real getReadModel", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-unreadable-"));
try {
const usageDir = nodePath.join(root, "provider-usage");
fs.mkdirSync(usageDir, { recursive: true });
const seatSession = "dev-impl@test-rig";
fs.writeFileSync(nodePath.join(usageDir, `${seatSession}.json`), "{ not json");
const db = createFullTestDb();
seedClaudeSeat(db, seatSession);
const svc = new ProviderServiceImpl({
db,
listRigs: () => [{ id: "rig-1" }],
env: emptyCodexHomeEnv(),
now: () => ASOF,
collectClaudeSignals: () => collectClaudeSignalsFromProviderUsageDirectory(usageDir, () => ASOF),
});
const model = await svc.getReadModel();
const claude = model.signals.filter((signal) => signal.provider === "claude");
expect(claude).toHaveLength(1);
expect(claude[0]).toMatchObject({
seatSession,
sourceClass: "unknown",
authority: "unknown",
unknownReason: "claude_statusline_cache_unreadable",
automationUse: "do_not_automate",
});
expect(claude[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
it("an exited latest Claude session plus matching stale cache emits zero Claude signals", async () => {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "provider-c3-exited-"));
try {
@@ -76,12 +76,20 @@ describe("collectClaudeStatuslineSignals — reader → provider_statusline / ex
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("malformed cache JSON → unknown(empty_reading), never throws", () => {
it("malformed cache JSON → unknown(unreadable_cache), never throws", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => "{ not json",
}));
expect(sigs[0]!.sourceClass).toBe("unknown");
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
});
it("a valid cache with no windows keeps empty_reading (distinct from an unreadable cache)", () => {
const sigs = collectClaudeStatuslineSignals(readerDeps({
listClaudeSeats: () => [{ seatSession: "dev-impl@rig" }],
readCacheRaw: () => JSON.stringify({ seatSession: "dev-impl@rig", asOf: CACHE_ASOF }),
}));
expect(sigs[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
});
@@ -111,14 +119,33 @@ describe("0.5.8 provider-usage compatibility bridge", () => {
const malformedCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(malformedCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(path.join(canonical, "[email protected]"));
fs.mkdirSync(path.join(canonical, "[email protected]"));
const wrongTypeCanonical = collectClaudeSignalsFromProviderUsageDirectory(canonical, () => ASOF, legacy);
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.usedPercent).toBeUndefined();
expect(wrongTypeCanonical.find((signal) => signal.seatSession === "legacy@rig")?.unknownReason)
.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
.toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
fs.rmSync(home, { recursive: true, force: true });
});
});
describe("collectClaudeSignalsFromProviderUsageDirectory — a malformed-only cache stays visible", () => {
it("reports unreadable_cache for a seat known only from a malformed file's name", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "provider-unreadable-"));
const directory = path.join(home, "state", "provider-usage");
fs.mkdirSync(directory, { recursive: true });
fs.writeFileSync(path.join(directory, "[email protected]"), "{ not json");
try {
const signals = collectClaudeSignalsFromProviderUsageDirectory(directory, () => ASOF);
expect(signals).toHaveLength(1);
expect(signals[0]!.seatSession).toBe("broken@rig");
expect(signals[0]!.sourceClass).toBe("unknown");
expect(signals[0]!.unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(signals[0]!.usedPercent).toBeUndefined();
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
import { CLAUDE_UNKNOWN_REASON, claudeStatuslineSignals } from "../src/domain/provider/provider-signals.js";
// Slice-04 (OPR.0.5.0.4) — Claude statusline lane normalization (§2 Claude lane + the
// 2026-07-31 RESEARCH verdict). Proof item 6: the statusline sidecar's rate_limits object
@@ -68,5 +68,21 @@ describe("claudeStatuslineSignals — Claude lane honesty", () => {
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
});
it("an unreadable cache gets its own reason, not the empty-reading one", () => {
const sigs = claudeStatuslineSignals({
seatSession: "dev-impl@rig",
cachePresent: true,
cacheUnreadable: true,
asOf: ASOF,
});
expect(sigs).toHaveLength(1);
expect(sigs[0].sourceClass).toBe("unknown");
expect(sigs[0].unknownReason).toBe(CLAUDE_UNKNOWN_REASON.unreadable_cache);
expect(sigs[0].unknownReason).not.toBe(CLAUDE_UNKNOWN_REASON.empty_reading);
expect(sigs[0].usedPercent).toBeUndefined();
expect(sigs[0].automationUse).toBe("do_not_automate");
});
});
@@ -275,6 +275,56 @@ 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()) {
+4 -3
View File
@@ -13,6 +13,7 @@ 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";
@@ -70,7 +71,7 @@ describe("GET /api/review/*", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -335,7 +336,7 @@ describe("GET /api/review/rig (OPR.0.4.4.22)", () => {
db = createDb(":memory:");
migrate(db, [
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
streamItemsSchema,
queueItemsSchema,
@@ -668,7 +669,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, eventsSchema, streamItemsSchema,
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema, streamItemsSchema,
queueItemsSchema, queueTransitionsSchema, missionControlActionsSchema,
queueItemSummarySchema,
]);
@@ -0,0 +1,86 @@
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,6 +370,23 @@ 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();
+2 -1
View File
@@ -7,6 +7,7 @@ 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";
@@ -133,7 +134,7 @@ describe("workflow route (WF3 FR-4 — close+recreate+rebind)", () => {
db = createDb();
migrate(db, [
outboxEntriesSchema,
coreSchema, bindingsSessionsSchema, eventsSchema,
coreSchema, bindingsSessionsSchema, rigArchiveSchema, eventsSchema,
queueItemsSchema, queueTransitionsSchema,
queueItemSummarySchema, queueItemEvidenceRefSchema,
workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema,
+2 -1
View File
@@ -7,6 +7,7 @@ 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";
@@ -582,7 +583,7 @@ describe("workflow routes — WF-2 structured error boundaries", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
@@ -13,6 +13,7 @@ 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";
@@ -188,7 +189,7 @@ describe("OPR.0.4.6.WF2 — spec language", () => {
migrate(db, [
outboxEntriesSchema,
coreSchema,
bindingsSessionsSchema,
bindingsSessionsSchema, rigArchiveSchema,
eventsSchema,
queueItemsSchema,
queueTransitionsSchema,
+49 -30
View File
@@ -48,6 +48,8 @@ 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 {
@@ -94,17 +96,24 @@ function agentColumns(contentWidth: number): AgentColumn[] {
];
}
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"],
];
/** 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"],
];
}
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
return [
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
@@ -709,7 +718,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 repeats on every row so scrolled rows keep it. */
/** 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. */
function instanceContentLines(
state: ViewState,
snap: FleetSnapshot,
@@ -801,7 +810,12 @@ 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 columns = instanceAgentColumns(contentWidth);
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)));
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;
@@ -810,10 +824,7 @@ function instanceContentLines(
let attentionUnknown = 0;
let openCount = 0;
let firstRig = true;
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;
for (const { rig, agents } of shownRigs) {
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
firstRig = false;
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
@@ -825,7 +836,7 @@ function instanceContentLines(
continue;
}
let previousPod: string | null = null;
for (const { pod, agent } of agents) {
for (const [index, { pod, agent }] of agents.entries()) {
const firstInPod = pod !== previousPod;
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
previousPod = pod;
@@ -836,18 +847,21 @@ 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: 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,
}),
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
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 }],
});
@@ -1944,7 +1958,12 @@ 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 visibleContent = content.slice(contentStart, contentStart + contentRows);
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 };
});
if (content.length > bodyRows) {
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
const up = scrollText.indexOf("↑");
@@ -6,6 +6,7 @@ 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";
@@ -118,16 +119,20 @@ 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;
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
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);
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
@@ -140,7 +145,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(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/\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);
@@ -319,3 +324,138 @@ 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);
});
});