mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 17:32:22 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfec68d756 | ||
|
|
c4ff692b3d | ||
|
|
b66c9888b7 | ||
|
|
37e0715c58 |
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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user