Compare commits

...
Author SHA1 Message Date
core-builder-b bfec68d756 fix(daemon): a context watch on a removed seat ends; only an ambiguous name skips
The previous change kept a context watch active when its seat's
generation was unknown: it skipped and measured again later. That
included a seat that is gone. Tearing down or removing a rig deletes its
nodes but not their watchdog jobs, and nothing would ever resolve the
name again. So an orphaned watch recorded a skip and emitted an event
every interval, indefinitely.

The engine now asks one more question when the generation is unknown:
does any node and occupant generation still carry the name?
- If so, the name is ambiguous for now (two unarchived generations of a
  seat). The watch skips and is measured again.
- If nothing carries it, the seat is gone and the watch ends, as it did
  before.
Startup answers with the plain by-name lookup. An engine without that
answer treats the seat as gone, as before.
2026-11-12 02:28:02 +00:00
core-builder-b c4ff692b3d fix(daemon): a context watch outlives a moment when its seat's generation is unknown
The engine now reads a context watchdog's generation from the seat its
name addresses. While that name names no single seat, there is no
generation. For example, both generations of a seat can be unarchived
for a moment while the old one is restored. The context-usage policy
used to end the watch for good in that case (terminal,
occupant_generation_unresolved), and later ticks only evaluate active
jobs. So the seat lost its context wake even after the name resolved
again.

An unresolved generation is now a skip with that reason. It is recorded
in the watch's history and emitted, and the watch stays active. Each
later tick measures it again, and once the name names one seat it
measures that seat's own transcript and context use. It never fires from
another node's numbers.
2026-11-12 02:11:54 +00:00
core-builder-b b66c9888b7 fix(daemon): a context watchdog reads its own seat's generation
The watchdog engine resolves a target's current occupant generation by
session name. It uses that generation to gate generation-bound wakes,
and to pick the transcript and sampled context use that a context-usage
watchdog measures. The lookup took the node of the newest session row
with that name.

An archived generation of the same seat can hold a newer row under that
name. For example: re-running a rig spec archives the stopped rig and
creates a new one with the same name. The old rig is then unarchived and
restored while the new one is stopped, and archived again. Until the
seat next started, its context watchdog read the archived node's
generation. It could bind to that node's transcript, or measure that
node's context use, and fire a context wake to the seat from another
node's numbers.

The engine now resolves the name to the seat it addresses, with the
resolver the delivery guard already uses, and reads that node's current
generation. That resolver keeps an unarchived seat over an archived
generation of it. When it can't name exactly one seat, it reports the
generation as unknown: the gate fails open and a context job waits, as
the engine already handles. A live seat reads the same generation as
before.
2026-11-12 01:56:29 +00: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
8 changed files with 341 additions and 36 deletions

No files matched your search

@@ -37,9 +37,13 @@ export function makeContextUsageThresholdPolicy(
if (threshold == null) {
return { action: "terminal", reason: "threshold_invalid" };
}
// A name that names no single seat right now (an archived generation of it restored for a moment, say)
// has no generation to measure. Skip and say so; the watch stays active and is measured again once
// the name resolves, never from another node's numbers. A name nothing carries any more is a seat
// that's gone (its rig or node removed): the watch ends.
if (!job.occupantGeneration) {
return {
action: "terminal",
action: job.occupantGenerationAmbiguous ? "skip" : "terminal",
reason: "occupant_generation_unresolved",
notes: { targetSession: job.target.session },
};
@@ -50,6 +50,8 @@ export interface PolicyJob {
/** The condition this job last told its target about (the receipt below), or null. */
lastFiredCondition: string | null;
occupantGeneration: string | null;
/** With no occupantGeneration: true when the name is still carried but names no single seat, false when it's gone. */
occupantGenerationAmbiguous?: boolean;
currentGenerationTranscriptPending: boolean;
requiredReceiptSatisfied: boolean;
requiredReceiptDeferred: boolean;
@@ -142,6 +142,10 @@ interface WatchdogPolicyEngineDeps {
* SKIPPING, so an unknown target-generation must never skip a legitimate wake (note-2 inversion).
*/
resolveTargetGeneration?: (sessionName: string) => string | null;
/** Whether any node and occupant generation still carry this name. When resolveTargetGeneration is
* unknown, a carried name is ambiguous (a context watch skips and measures again); one carried by
* nothing is a seat that's gone (the watch ends). Absent → gone, as before. */
targetNameCarried?: (sessionName: string) => boolean;
/**
* Issue #1180 — the target seat's latest context-usage sample under its live
* occupant generation (generation-guarded by the jobs repository). Null = no
@@ -275,6 +279,7 @@ export class WatchdogPolicyEngine {
private readonly now: () => Date;
private readonly policies: Map<string, Policy>;
private readonly resolveTargetGeneration?: (sessionName: string) => string | null;
private readonly targetNameCarried?: (sessionName: string) => boolean;
private readonly resolveContextUsage?: WatchdogPolicyEngineDeps["resolveContextUsage"];
private readonly resolvePreDeliveryTerminalReason?: (input: { jobId: string }) => string | null;
private readonly resolveQueueWait?: WatchdogPolicyEngineDeps["resolveQueueWait"];
@@ -287,6 +292,7 @@ export class WatchdogPolicyEngine {
this.eventBus = deps.eventBus;
this.deliver = deps.deliver;
this.resolveTargetGeneration = deps.resolveTargetGeneration;
this.targetNameCarried = deps.targetNameCarried;
this.resolveContextUsage = deps.resolveContextUsage;
this.resolvePreDeliveryTerminalReason = deps.resolvePreDeliveryTerminalReason;
this.onWakeAttempt = deps.onWakeAttempt;
@@ -432,6 +438,8 @@ export class WatchdogPolicyEngine {
lastFiredCondition: job.lastFiredCondition,
occupantGeneration,
currentGenerationTranscriptPending,
occupantGenerationAmbiguous: isContextUsageThreshold && !occupantGeneration
&& (this.targetNameCarried?.(job.targetSession) ?? false),
requiredReceiptSatisfied,
requiredReceiptDeferred,
};
+5 -2
View File
@@ -2383,8 +2383,11 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (i-c) fire-time target-generation gate: resolve the target's LIVE occupant-generation (P12
// occupant_tenures) so a generation-bound wake is refused once the target has handed over.
// UNKNOWN (null) fails open → deliver. A drop of this line disables the gate silently (gen-bound
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts.
resolveTargetGeneration: (s) => sessionRegistry.currentOccupantGenerationForSession(s),
// wakes would fire at the successor) — pinned in watchdog-target-gen-wiring.test.ts. Read by the
// seat the name addresses, not the newest row with that name: an archived generation of the same
// seat can hold a newer one, and a context watchdog would then read that node's telemetry.
resolveTargetGeneration: (s) => resolveGuardTarget(db, s)?.occupant ?? null,
targetNameCarried: (s) => sessionRegistry.currentOccupantGenerationForSession(s) !== null,
// Issue #1180 — sampled context use for token/percent context watchdogs,
// generation-guarded against a predecessor's frozen telemetry.
resolveContextUsage: (s, occupantGeneration) =>
@@ -24,6 +24,8 @@ import { WatchdogJobsRepository } from "../src/domain/watchdog-jobs-repository.j
import { WatchdogPolicyEngine } from "../src/domain/watchdog-policy-engine.js";
import { WatchdogScheduler } from "../src/domain/watchdog-scheduler.js";
import { watchdogRoutes } from "../src/routes/watchdog.js";
import { resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
describe("context-usage-threshold watchdog", () => {
let db: Database.Database;
@@ -623,3 +625,79 @@ describe("context-usage-threshold registration", () => {
expect(await response.json()).toMatchObject({ error: "watched_file_unresolved" });
});
});
describe("context-usage-threshold watchdog on a name two seat generations share", () => {
let db: Database.Database;
let tmp: string;
beforeEach(() => {
db = createDb();
migrate(db, ALL_MIGRATIONS);
tmp = join(tmpdir(), `context-watchdog-ambiguous-${Date.now()}-${Math.random()}`);
mkdirSync(tmp, { recursive: true });
});
afterEach(() => {
db.close();
rmSync(tmp, { recursive: true, force: true });
});
const seat = (rig: string, node: string, generation: string, transcript: string) => {
db.prepare("INSERT INTO rigs (id, name) VALUES (?, 'seat-rig')").run(rig);
db.prepare("INSERT INTO nodes (id, rig_id, logical_id) VALUES (?, ?, 'dev.impl')").run(node, rig);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status) VALUES (?, ?, 'dev-impl@seat-rig', 'detached')").run(`session-${node}`, node);
db.prepare(`INSERT INTO occupant_tenures (id, node_id, generation_ordinal, generation_uuid, kind, boot_at)
VALUES (?, ?, 1, ?, 'initial', '2026-08-28T09:00:00.000Z')`).run(`tenure-${node}`, node, generation);
writeFileSync(transcript, "1234567890");
db.prepare(`INSERT INTO context_usage (node_id, session_id, session_name, availability, transcript_path, sampled_at)
VALUES (?, ?, 'dev-impl@seat-rig', 'known', ?, '2026-08-28T09:00:01.000Z')`).run(node, `native-${node}`, transcript);
};
// As startup wires it.
const watch = () => {
const repo = new WatchdogJobsRepository(db);
const history = new WatchdogHistoryLog(db);
const deliveries: Array<{ targetSession: string; message: string }> = [];
const sessions = new SessionRegistry(db);
const engine = new WatchdogPolicyEngine({ jobsRepo: repo, historyLog: history, eventBus: new EventBus(db),
deliver: async (request) => { deliveries.push(request); return { status: "ok" as const }; },
resolveTargetGeneration: (s) => resolveGuardTarget(db, s)?.occupant ?? null,
targetNameCarried: (s) => sessions.currentOccupantGenerationForSession(s) !== null });
const job = repo.register({ policy: "context-usage-threshold",
specYaml: "policy: context-usage-threshold\ngenerated_by: continuity-policy-materializer\ncontinuity_mode: managed-compaction\n"
+ "target:\n session: dev-impl@seat-rig\nmessage: Context threshold crossed.\n",
targetSession: "dev-impl@seat-rig", intervalSeconds: 60, registeredBySession: "daemon@kernel", watchedFilePath: null, thresholdBytes: 8 });
return { repo, history, deliveries, engine, job };
};
it("skips while the name names no single seat, stays active, then fires on its own seat's numbers", async () => {
// Two unarchived generations of seat dev-impl@seat-rig (the old rig unarchived for its restore), both stopped.
const oldTranscript = join(tmp, "old.jsonl"), currentTranscript = join(tmp, "current.jsonl");
seat("rig-old", "node-old", "gen-old", oldTranscript);
seat("rig-current", "node-current", "gen-current", currentTranscript);
const { repo, history, deliveries, engine, job } = watch();
expect(resolveGuardTarget(db, "dev-impl@seat-rig")).toBeNull();
const ambiguous = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(ambiguous.outcome).toMatchObject({ action: "skip", reason: "occupant_generation_unresolved" });
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ state: "active", watchedFilePath: null });
expect(history.listForJob(job.jobId)[0]).toMatchObject({ outcome: "skipped", skipReason: "occupant_generation_unresolved" });
expect(deliveries).toEqual([]);
db.prepare("UPDATE rigs SET archived_at = datetime('now') WHERE id = 'rig-old'").run();
const resolved = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(resolved.outcome.action).toBe("send");
expect(deliveries).toEqual([{ targetSession: "dev-impl@seat-rig", message: "Context threshold crossed." }]);
expect(repo.getByIdOrThrow(job.jobId)).toMatchObject({ watchedFilePath: currentTranscript, watchedFileGeneration: "gen-current",
lastFiredGeneration: "gen-current" });
});
it("ends the watch of a seat that's gone: its rig removed, nothing carries the name", async () => {
seat("rig-current", "node-current", "gen-current", join(tmp, "current.jsonl"));
const { repo, deliveries, engine, job } = watch();
db.prepare("DELETE FROM rigs WHERE id = 'rig-current'").run();
expect(new SessionRegistry(db).currentOccupantGenerationForSession("dev-impl@seat-rig")).toBeNull();
const gone = await engine.evaluate(repo.getByIdOrThrow(job.jobId));
expect(gone.outcome).toMatchObject({ action: "terminal", reason: "occupant_generation_unresolved" });
expect(repo.getByIdOrThrow(job.jobId).state).toBe("terminal");
expect(deliveries).toEqual([]);
});
});
@@ -1,6 +1,10 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { resolveGuardTarget } from "../src/domain/seat-delivery-guard.js";
// GHOST-STAGE (i-c) WIRING PIN. The fire-time target-generation gate in WatchdogPolicyEngine is INERT
// unless startup injects the REAL live-generation resolver — with resolveTargetGeneration absent the
@@ -14,7 +18,9 @@ describe("(i-c) wiring pin — startup injects the real target-generation resolv
);
it("passes resolveTargetGeneration to the WatchdogPolicyEngine, wired to the live occupant-generation", () => {
expect(src).toMatch(/resolveTargetGeneration:\s*\(s\)\s*=>\s*sessionRegistry\.currentOccupantGenerationForSession\(s\)/);
expect(src).toMatch(/resolveTargetGeneration:\s*\(s\)\s*=>\s*resolveGuardTarget\(db,\s*s\)\?\.occupant\s*\?\?\s*null/);
// Without it a context watch on an ambiguous name ends for good instead of measuring again once it resolves.
expect(src).toMatch(/targetNameCarried:\s*\(s\)\s*=>\s*sessionRegistry\.currentOccupantGenerationForSession\(s\)\s*!==\s*null/);
});
it("passes the queue's terminal-timer resolver to the pre-delivery seam", () => {
@@ -27,3 +33,48 @@ describe("(i-c) wiring pin — startup injects the real target-generation resolv
expect(src).toMatch(/deliver:\s*makeWatchdogDeliver\(\{\s*sessionTransport,\s*outbox:\s*outboxHandlerInstance/);
});
});
describe("the watchdog's target generation is the addressed seat's, not the newest row's with that name", () => {
const SEAT = "dev-impl@seat-rig";
function seed() {
const db = createFullTestDb();
const rigs = new RigRepository(db), sessions = new SessionRegistry(db);
const start = (nodeId: string) => { const s = sessions.registerSession(nodeId, SEAT); sessions.updateStatus(s.id, "running");
sessions.updateBinding(nodeId, { tmuxSession: SEAT, tmuxPane: "%1" }); return s.id; };
const down = (nodeId: string, sessionId: string) => { sessions.markDetached(sessionId); sessions.clearBinding(nodeId); };
const generation = (nodeId: string) => sessions.currentOccupantTenure(nodeId)!.generationUuid;
return { db, rigs, sessions, start, down, generation };
}
it("reads the current seat while an archived generation of it holds a newer row under the name", async () => {
const { db, rigs, sessions, start, down, generation } = seed();
// rig up, rig down; rig up the same spec again (the stopped rig is archived); rig down.
const old = rigs.createRig("seat-rig"), oldNode = rigs.addNode(old.id, "dev.impl", { runtime: "codex" });
down(oldNode.id, start(oldNode.id));
rigs.archiveRig(old.id);
const current = rigs.createRig("seat-rig"), node = rigs.addNode(current.id, "dev.impl", { runtime: "codex" });
down(node.id, start(node.id));
// The old rig unarchived and restored, stopped and archived again: its row under the name is now the newest.
rigs.unarchiveRig(old.id);
await new Promise((resolve) => setTimeout(resolve, 2)); // a later millisecond, so its row sorts newer
down(oldNode.id, start(oldNode.id));
rigs.archiveRig(old.id);
expect(sessions.currentOccupantGenerationForSession(SEAT)).toBe(generation(oldNode.id));
expect(resolveGuardTarget(db, SEAT)?.occupant).toBe(generation(node.id));
// Started again, the seat's new generation is the one read.
start(node.id);
expect(resolveGuardTarget(db, SEAT)?.occupant).toBe(generation(node.id));
expect(sessions.currentOccupantGenerationForSession(SEAT)).toBe(generation(node.id));
db.close();
});
it("reads a live seat's current generation, as before", () => {
const { db, rigs, sessions, start, generation } = seed();
const node = rigs.addNode(rigs.createRig("seat-rig").id, "dev.impl", { runtime: "codex" });
start(node.id);
expect(resolveGuardTarget(db, SEAT)?.occupant).toBe(generation(node.id));
expect(resolveGuardTarget(db, SEAT)?.occupant).toBe(sessions.currentOccupantGenerationForSession(SEAT));
db.close();
});
});
+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);
});
});