mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(daemon): show confirmed missing tmux sessions as detached (#1074)
When tmux confirms a registered seat's session is gone, rig ps --nodes, rig seat status and the TUI show the seat as detached, with the cause and recovery in human output. It's a read projection: stored session status, occupant history, archive and cmux attach behave as before.
This commit is contained in:
20 files changed
+417
-62
No files matched your search
@@ -75,6 +75,7 @@ interface NodeEntry {
|
||||
needsInput: { count: number; reason: string | null };
|
||||
decidedBy: string | null;
|
||||
} | null;
|
||||
identityVerdict?: { reason?: string | null } | null;
|
||||
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
|
||||
* NEVER derived from terminalActive. */
|
||||
hasAssignedWork?: boolean;
|
||||
@@ -592,6 +593,12 @@ function needsAttention(node: NodeEntry): boolean {
|
||||
|| node.agentActivity?.state === "needs_input";
|
||||
}
|
||||
|
||||
function missingSessionGuidance(node: NodeEntry): string | null {
|
||||
return node.identityVerdict?.reason === "session_missing"
|
||||
? "tmux session missing; restore or relaunch the seat"
|
||||
: null;
|
||||
}
|
||||
|
||||
// OPR.0.4.0.34 — the compact orch field set (PRD FR-4). Carries the
|
||||
// source-available identity + state + resume-summary fields an orchestrator
|
||||
// needs at a glance, WITHOUT leaking the resume token value or resumeCommand
|
||||
@@ -1206,9 +1213,9 @@ async function handleNodes(
|
||||
console.log(padCompactNodeRow("RIG", "SESSION", "LIFECYCLE", "ACTIVITY", "WORK", "REASON"));
|
||||
for (const n of humanList as NodeEntry[]) {
|
||||
const attn = needsAttention(n);
|
||||
const reason = attn
|
||||
const reason = missingSessionGuidance(n) ?? (attn
|
||||
? (n.latestError ? truncate(n.latestError, 40) : n.agentActivity?.reason ?? "—")
|
||||
: "—";
|
||||
: "—");
|
||||
console.log(padCompactNodeRow(
|
||||
n.rigName,
|
||||
n.canonicalSessionName ?? "—",
|
||||
@@ -1242,7 +1249,7 @@ async function handleNodes(
|
||||
formatActivity(n),
|
||||
formatContextUsage(n.contextUsage),
|
||||
n.restoreOutcome,
|
||||
n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}` : "—",
|
||||
missingSessionGuidance(n) ?? (n.latestError ? truncate(n.latestError, 30) : n.heldReason ? `held: ${truncate(n.heldReason, 25)}` : "—"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ interface SeatStatusResponse {
|
||||
};
|
||||
};
|
||||
session_status: string | null;
|
||||
session_reason?: string | null;
|
||||
startup_status: string | null;
|
||||
occupant_lifecycle: string;
|
||||
continuity_outcome: string | null;
|
||||
@@ -149,6 +150,10 @@ function display(value: string | null | undefined, empty = "none"): string {
|
||||
return value ?? empty;
|
||||
}
|
||||
|
||||
function sessionReason(reason: string | null | undefined): string | null {
|
||||
return reason === "session_missing" ? "tmux session missing; restore or relaunch the seat" : null;
|
||||
}
|
||||
|
||||
function printHuman(status: SeatStatusResponse): void {
|
||||
console.log(`Seat ${status.seat_ref}`);
|
||||
console.log(`Rig: ${status.rig_name}`);
|
||||
@@ -160,6 +165,8 @@ function printHuman(status: SeatStatusResponse): void {
|
||||
console.log("Automatic input pauses while on, including writing lifecycle. Disabling does not replay held messages.");
|
||||
}
|
||||
console.log(`Session: ${display(status.session_status, "unknown")}`);
|
||||
const reason = sessionReason(status.session_reason);
|
||||
if (reason) console.log(`Reason: ${reason}`);
|
||||
if (status.permissions) {
|
||||
const p = status.permissions;
|
||||
const formatSource = (source: string): string => {
|
||||
|
||||
@@ -66,9 +66,11 @@ function runningDeps(port: number): StatusDeps {
|
||||
|
||||
function makeFatNode(rigId: string, rigName: string, logicalId: string, opts?: {
|
||||
lifecycleState?: string;
|
||||
sessionStatus?: string;
|
||||
startupStatus?: string;
|
||||
activityState?: string;
|
||||
activityReason?: string;
|
||||
identityReason?: string;
|
||||
hasAssignedWork?: boolean;
|
||||
assignedWorkCount?: number;
|
||||
pendingWorkCount?: number;
|
||||
@@ -90,10 +92,11 @@ function makeFatNode(rigId: string, rigName: string, logicalId: string, opts?: {
|
||||
hostSelfId: "host-84c37990",
|
||||
nodeKind: "agent" as const,
|
||||
runtime: "claude-code",
|
||||
sessionStatus: "running",
|
||||
sessionStatus: opts?.sessionStatus ?? "running",
|
||||
startupStatus: opts?.startupStatus ?? "ready",
|
||||
restoreOutcome: "n-a",
|
||||
lifecycleState: opts?.lifecycleState ?? "running",
|
||||
identityVerdict: opts?.identityReason ? { reason: opts.identityReason } : null,
|
||||
tmuxAttachCommand: `tmux attach -t ${logicalId.replace(".", "-")}@${rigName}`,
|
||||
resumeCommand: `claude --resume /path/to/session/${logicalId}`,
|
||||
latestError: opts?.latestError ?? null,
|
||||
@@ -146,6 +149,10 @@ const RIG_A_NODES = [
|
||||
blockedWorkCount: 1,
|
||||
contextPercent: 92,
|
||||
}),
|
||||
makeFatNode("rig-a", "openrig-build", "ops.missing", {
|
||||
lifecycleState: "detached", sessionStatus: "detached", activityState: "unknown",
|
||||
identityReason: "session_missing",
|
||||
}),
|
||||
];
|
||||
|
||||
const RIG_B_NODES = [
|
||||
@@ -541,6 +548,15 @@ describe("OPR.0.4.0.25 — rig ps token-safe defaults", () => {
|
||||
expect(assignedLine!.slice(22 + 38 + 11 + 14, 22 + 38 + 11 + 14 + 6).trim()).toBe("6");
|
||||
});
|
||||
|
||||
it("human node views explain a missing tmux session and the recovery path", async () => {
|
||||
for (const extra of [[], ["--full"]]) {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--session", "ops-missing@openrig-build", ...extra]);
|
||||
});
|
||||
expect(logs.join("\n")).toContain("tmux session missing; restore or relaunch the seat");
|
||||
}
|
||||
});
|
||||
|
||||
it("--full human table uses full-detail columns", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rig", "ps", "--nodes", "-A", "--full"]);
|
||||
|
||||
@@ -250,6 +250,19 @@ describe("rig seat status", () => {
|
||||
expect(output).toContain("Previous occupant: none");
|
||||
});
|
||||
|
||||
it("explains a confirmed missing tmux session", async () => {
|
||||
const deps = makeDeps({
|
||||
status: 200,
|
||||
data: { ...STATUS, session_status: "detached", session_reason: "session_missing" },
|
||||
}, []);
|
||||
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCommand(deps).parseAsync(["node", "rig", "seat", "status", "dev-impl@seat-rig"]);
|
||||
});
|
||||
|
||||
expect(logs.join("\n")).toContain("Reason: tmux session missing; restore or relaunch the seat");
|
||||
});
|
||||
|
||||
it("returns a nonzero status for an unknown seat", async () => {
|
||||
const deps = makeDeps({
|
||||
status: 404,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type Database from "better-sqlite3";
|
||||
import { decodeTime } from "ulid";
|
||||
import { resolveActiveOccupantRow } from "./active-occupant.js";
|
||||
import type { NodeInventoryEntry, NodeDetailEntry, NodeDetailPeer, NodeDetailEdge, NodeDetailCompactSpec, NodeRestoreOutcome, NodeOriented, NodeLifecycleState, Binding, RestoreResult, NodeRecoveryGuidance, Snapshot, WorkspaceSpec, SeatIdentityVerdict, SeatIdentityVerdictKind, AgentActivity, SeatActivity } from "./types.js";
|
||||
import { identityVerdictDownranksRunning } from "./types.js";
|
||||
import { identityVerdictConfirmsSessionMissing, identityVerdictDownranksRunning } from "./types.js";
|
||||
import { SeatIdentityStore } from "./seat-identity-store.js";
|
||||
import { buildOrientedMap } from "./startup-proof.js";
|
||||
import type { RuntimeAdapter } from "./runtime-adapter.js";
|
||||
@@ -236,16 +236,23 @@ export function deriveNodeLifecycleState(input: {
|
||||
return "detached";
|
||||
}
|
||||
|
||||
/**
|
||||
* Project occupant lifecycle using a verdict already gated to the current binding.
|
||||
* Confirmed session absence suppresses stored active state without rewriting it.
|
||||
*/
|
||||
function deriveOccupantLifecycle(
|
||||
row: InventoryRow,
|
||||
identityVerdict?: SeatIdentityVerdictKind | null,
|
||||
identityVerdict: SeatIdentityVerdict | null,
|
||||
): NodeInventoryEntry["occupantLifecycle"] {
|
||||
if (row.occupant_lifecycle === "active" && identityVerdictConfirmsSessionMissing(identityVerdict)) {
|
||||
return "unknown";
|
||||
}
|
||||
if (row.occupant_lifecycle) {
|
||||
return row.occupant_lifecycle as NodeInventoryEntry["occupantLifecycle"];
|
||||
}
|
||||
// OPR.0.4.3.19 — the derived `active` occupant requires a verified (or
|
||||
// not-yet-observed) pane identity, mirroring the lifecycleState gate.
|
||||
if (row.session_status === "running" && !identityVerdictDownranksRunning(identityVerdict)) {
|
||||
if (row.session_status === "running" && !identityVerdictDownranksRunning(identityVerdict?.verdict)) {
|
||||
return "active";
|
||||
}
|
||||
return "unknown";
|
||||
@@ -613,6 +620,11 @@ interface InventoryBuildContext {
|
||||
orienteds: Map<string, NodeOriented>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shared inventory projection from a registration and its rig context.
|
||||
* Apply only current-occupant identity evidence and retain stored status alongside
|
||||
* effective status so readers can distinguish absence from registration history.
|
||||
*/
|
||||
function buildInventoryEntry(
|
||||
db: Database.Database,
|
||||
row: InventoryRow,
|
||||
@@ -636,8 +648,12 @@ function buildInventoryEntry(
|
||||
// seat), it does NOT itself down-rank. Only a MATCHING mismatch/pane_missing
|
||||
// verdict down-ranks.
|
||||
const identityVerdict = applicableVerdict(identityVerdicts.get(row.node_id) ?? null, row);
|
||||
// Keep registry history intact; only an applicable, explicit absence changes
|
||||
// the current status. Unavailable tmux and process mismatches are not absence.
|
||||
const sessionStatus = row.session_status === "running" && identityVerdictConfirmsSessionMissing(identityVerdict)
|
||||
? "detached" : row.session_status;
|
||||
const lifecycleState = deriveNodeLifecycleState({
|
||||
sessionStatus: row.session_status,
|
||||
sessionStatus,
|
||||
startupStatus: row.startup_status,
|
||||
restoreOutcome,
|
||||
nodeId: row.node_id,
|
||||
@@ -659,11 +675,12 @@ function buildInventoryEntry(
|
||||
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
|
||||
nodeKind: deriveNodeKind(row.runtime),
|
||||
runtime: row.runtime,
|
||||
sessionStatus: row.session_status,
|
||||
sessionStatus,
|
||||
storedSessionStatus: row.session_status,
|
||||
// Match the graph's current-status projection without rewriting the startup
|
||||
// result. Applicable mismatch (including ambiguity) or pane_missing overrides it.
|
||||
storedStartupStatus: row.startup_status as NodeInventoryEntry["startupStatus"],
|
||||
startupStatus: row.session_status === "running"
|
||||
startupStatus: sessionStatus === "running"
|
||||
&& identityVerdictDownranksRunning(identityVerdict?.verdict)
|
||||
? "attention_required"
|
||||
: row.startup_status as NodeInventoryEntry["startupStatus"],
|
||||
@@ -673,7 +690,7 @@ function buildInventoryEntry(
|
||||
// no proof events, matching deriveOriented's no-challenge branch).
|
||||
oriented: orienteds.get(row.node_id) ?? "n-a",
|
||||
lifecycleState,
|
||||
occupantLifecycle: deriveOccupantLifecycle(row, identityVerdict?.verdict ?? null),
|
||||
occupantLifecycle: deriveOccupantLifecycle(row, identityVerdict),
|
||||
continuityOutcome: deriveContinuityOutcome(row, restore),
|
||||
handoverResult: row.handover_result as NodeInventoryEntry["handoverResult"] ?? null,
|
||||
previousOccupant: row.previous_occupant,
|
||||
@@ -707,7 +724,7 @@ function buildInventoryEntry(
|
||||
// OPR.0.4.3.19 — the liveness identity verdict (third axis). null when
|
||||
// never observed; carries evidence on mismatch/missing.
|
||||
identityVerdict,
|
||||
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, row.session_status),
|
||||
heldReason: deriveHeldReason(db, row.rig_id, row.node_id, sessionStatus),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1062,23 +1079,24 @@ export function attachTerminalActivityAndWork(
|
||||
const seatActivity = deps.seatActivity ?? null;
|
||||
const assignedByDest = readAssignedWorkBySession(deps.db);
|
||||
return entries.map((entry) => {
|
||||
let terminalActive: boolean | null | undefined = undefined;
|
||||
const sessionMissing = identityVerdictConfirmsSessionMissing(entry.identityVerdict);
|
||||
let terminalActive: boolean | null | undefined = sessionMissing ? false : undefined;
|
||||
// ARCH RULING 3a947fb1 (FR-7 additive): project the RAW lastActivityAt fact
|
||||
// alongside terminalActive from the SAME observation — honest-absence ladder
|
||||
// (obs → value, no obs → null, no service → undefined). No ageSeconds
|
||||
// sibling; age is derived renderer-side from this fact + a reader clock (C3).
|
||||
let lastActivityAt: string | null | undefined = undefined;
|
||||
if (seatActivity && entry.canonicalSessionName) {
|
||||
let lastActivityAt: string | null | undefined = sessionMissing ? null : undefined;
|
||||
if (!sessionMissing && seatActivity && entry.canonicalSessionName) {
|
||||
const obs = seatActivity.getSeatActivity(entry.canonicalSessionName);
|
||||
terminalActive = obs ? obs.isActiveWithinWindow : null;
|
||||
lastActivityAt = obs ? obs.lastActivityAt : null;
|
||||
}
|
||||
// S19 — the arbitrated taxonomy state from the ONE oracle, display pre-derived
|
||||
// through the single bridge (consumers render, never re-arbitrate).
|
||||
let activityState: NodeInventoryEntry["activityState"] = undefined;
|
||||
let activityState: NodeInventoryEntry["activityState"] = sessionMissing ? null : undefined;
|
||||
// Capability-checked: a partial injected double without the S19 surface keeps the
|
||||
// pre-taxonomy shape (undefined) rather than faking a null oracle answer.
|
||||
if (seatActivity && entry.canonicalSessionName && typeof seatActivity.getSeatStateBySession === "function") {
|
||||
if (!sessionMissing && seatActivity && entry.canonicalSessionName && typeof seatActivity.getSeatStateBySession === "function") {
|
||||
const arb = seatActivity.getSeatStateBySession(entry.canonicalSessionName);
|
||||
activityState = arb
|
||||
? {
|
||||
@@ -1247,6 +1265,20 @@ export async function attachAgentActivity(
|
||||
const sampledAt = deps.now ?? new Date();
|
||||
const captureFallback = deps.captureFallback ?? false;
|
||||
return Promise.all(entries.map(async (entry) => {
|
||||
// A cached hook, pane sample or motion cannot revive a confirmed absent
|
||||
// session. Preserve the identity observation's provenance without probing.
|
||||
if (identityVerdictConfirmsSessionMissing(entry.identityVerdict)) {
|
||||
return {
|
||||
...entry,
|
||||
agentActivity: {
|
||||
state: "unknown",
|
||||
reason: "session_missing",
|
||||
evidenceSource: "tmux_session",
|
||||
sampledAt: entry.identityVerdict!.observedAt,
|
||||
evidence: entry.canonicalSessionName,
|
||||
},
|
||||
};
|
||||
}
|
||||
// ACTIVITY D1+D2 — the MOTION reading, resolved once and applied to every exit below.
|
||||
const motion = entry.canonicalSessionName
|
||||
? deps.seatActivity?.getSeatActivity(entry.canonicalSessionName) ?? null
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import type { AgentActivity, NodeInventoryEntry, NodeLifecycleState, RigLifecycleState } from "./types.js";
|
||||
import { identityVerdictConfirmsSessionMissing } from "./types.js";
|
||||
import {
|
||||
countAssignedWorkForEntry,
|
||||
getNodeInventoryForAllRigs,
|
||||
@@ -175,7 +176,7 @@ export function deriveRigLifecycleState(nodeStates: NodeLifecycleState[]): RigLi
|
||||
*/
|
||||
export function isEffectivelyRunning(node: NodeInventoryEntry): boolean {
|
||||
if (node.sessionStatus !== "running") return false;
|
||||
if (node.identityVerdict?.reason === "session_missing") return false;
|
||||
if (identityVerdictConfirmsSessionMissing(node.identityVerdict)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -270,7 +271,7 @@ export class PsProjectionService {
|
||||
// so the two counts never disagree on liveness. NEVER mutates
|
||||
// `sessions.status`.
|
||||
const sessionMissingRunning = inventory.filter(
|
||||
(n) => n.sessionStatus === "running" && !isEffectivelyRunning(n),
|
||||
(n) => n.storedSessionStatus === "running" && !isEffectivelyRunning(n),
|
||||
).length;
|
||||
const effectiveRunningCount = Math.max(0, r.running_count - sessionMissingRunning);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RigRepository } from "./rig-repository.js";
|
||||
import { deriveCanonicalFromEntry, getNodeInventory } from "./node-inventory.js";
|
||||
import { parseSessionName } from "./session-name.js";
|
||||
import type { NodeInventoryEntry } from "./types.js";
|
||||
import type { NodeInventoryEntry, SeatIdentityVerdict } from "./types.js";
|
||||
import { NativePermissionStore, type StoredNativePermissionSelection, type ResolvedSeatPermission } from "./native-permission-store.js";
|
||||
import { AppliedLaunchObservationStore, type StoredAppliedLaunchObservation } from "./applied-launch-observation-store.js";
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SeatStatus {
|
||||
runtime: string | null;
|
||||
current_occupant: string | null;
|
||||
session_status: string | null;
|
||||
session_reason: SeatIdentityVerdict["reason"];
|
||||
startup_status: NodeInventoryEntry["startupStatus"];
|
||||
occupant_lifecycle: NodeInventoryEntry["occupantLifecycle"];
|
||||
continuity_outcome: NodeInventoryEntry["continuityOutcome"];
|
||||
@@ -113,6 +114,7 @@ export class SeatStatusService {
|
||||
runtime: entry.runtime,
|
||||
current_occupant: entry.canonicalSessionName,
|
||||
session_status: entry.sessionStatus,
|
||||
session_reason: entry.identityVerdict?.reason ?? null,
|
||||
startup_status: entry.startupStatus,
|
||||
occupant_lifecycle: entry.occupantLifecycle,
|
||||
continuity_outcome: entry.continuityOutcome,
|
||||
|
||||
@@ -602,6 +602,12 @@ export interface SeatIdentityVerdict {
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
/** Only a confirmed missing session proves the seat is no longer running.
|
||||
* Callers must first gate the verdict to the current session and binding. */
|
||||
export function identityVerdictConfirmsSessionMissing(verdict: SeatIdentityVerdict | null | undefined): boolean {
|
||||
return verdict?.reason === "session_missing";
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.19 — the two verdict kinds that down-rank a `running`/`active`
|
||||
* projection to a non-green state. `verified`, `tmux_unavailable`, and an
|
||||
@@ -640,7 +646,10 @@ export interface NodeInventoryEntry {
|
||||
attachmentType?: "tmux" | "external_cli" | null;
|
||||
nodeKind: "agent" | "infrastructure";
|
||||
runtime: string | null;
|
||||
/** Effective current status; confirmed session absence projects as detached. */
|
||||
sessionStatus: string | null;
|
||||
/** Persisted session status, retained for history and registry-based rollups. */
|
||||
storedSessionStatus?: string | null;
|
||||
// Current projection: an applicable identity failure down-ranks a running
|
||||
// session to attention_required; the stored startup result stays unchanged.
|
||||
startupStatus: "pending" | "ready" | "attention_required" | "failed" | null;
|
||||
|
||||
@@ -16,6 +16,7 @@ interface NodeInventoryStubEntry {
|
||||
logicalId: string;
|
||||
canonicalSessionName: string | null;
|
||||
sessionStatus: string | null;
|
||||
storedSessionStatus?: string | null;
|
||||
attachmentType: string | null;
|
||||
}
|
||||
|
||||
@@ -99,11 +100,13 @@ rigCmuxRoutes.post("/launch", async (c) => {
|
||||
const launchableByLogical = new Map<string, string>();
|
||||
const missing: MissingSeat[] = [];
|
||||
const nonTmuxIds = new Set<string>();
|
||||
const STALE_STATUSES = new Set(["exited", "detached"]);
|
||||
const EARLY_EXIT_STATUSES: Record<string, true> = { exited: true, detached: true };
|
||||
const MISSING_SESSION_STATUSES: Record<string, true> = { exited: true, detached: true };
|
||||
|
||||
// Collect candidates: seats with a tmux-compatible canonical name.
|
||||
// Track original sessionStatus for reason classification.
|
||||
interface Candidate { logicalId: string; sessionName: string; sessionStatus: string | null }
|
||||
// Track projected status for the response and stored status for main-compatible
|
||||
// readiness timing: projected detached + stored running still gets the wait.
|
||||
interface Candidate { logicalId: string; sessionName: string; sessionStatus: string | null; storedSessionStatus: string | null }
|
||||
const candidates: Candidate[] = [];
|
||||
const noSessionIds = new Set<string>();
|
||||
|
||||
@@ -117,7 +120,7 @@ rigCmuxRoutes.post("/launch", async (c) => {
|
||||
missing.push({ logicalId: entry.logicalId, reason: "non-tmux" });
|
||||
continue;
|
||||
}
|
||||
candidates.push({ logicalId: entry.logicalId, sessionName: entry.canonicalSessionName, sessionStatus: entry.sessionStatus });
|
||||
candidates.push({ logicalId: entry.logicalId, sessionName: entry.canonicalSessionName, sessionStatus: entry.sessionStatus, storedSessionStatus: entry.storedSessionStatus ?? entry.sessionStatus });
|
||||
}
|
||||
|
||||
// Bounded readiness wait with re-read for no-session seats.
|
||||
@@ -138,7 +141,7 @@ rigCmuxRoutes.post("/launch", async (c) => {
|
||||
if (!noSessionIds.has(entry.logicalId)) continue;
|
||||
if (entry.canonicalSessionName && (entry.attachmentType == null || entry.attachmentType === "tmux")) {
|
||||
noSessionIds.delete(entry.logicalId);
|
||||
pending.set(entry.logicalId, { logicalId: entry.logicalId, sessionName: entry.canonicalSessionName, sessionStatus: entry.sessionStatus });
|
||||
pending.set(entry.logicalId, { logicalId: entry.logicalId, sessionName: entry.canonicalSessionName, sessionStatus: entry.sessionStatus, storedSessionStatus: entry.storedSessionStatus ?? entry.sessionStatus });
|
||||
noSessionDiscovered++;
|
||||
}
|
||||
}
|
||||
@@ -158,12 +161,9 @@ rigCmuxRoutes.post("/launch", async (c) => {
|
||||
}
|
||||
}
|
||||
if (pending.size === 0 && noSessionIds.size === 0) break;
|
||||
// Early-exit when no progress was made this cycle (no new live sessions
|
||||
// AND no new sessions discovered from no-session seats) and all remaining
|
||||
// pending are known-stale.
|
||||
if (!firstPass && foundThisCycle === 0 && noSessionDiscovered === 0 && noSessionIds.size === 0) {
|
||||
const allPendingStale = pending.size === 0 || [...pending.values()].every((c) => STALE_STATUSES.has(c.sessionStatus ?? ""));
|
||||
if (allPendingStale) break;
|
||||
const allPendingTerminal = pending.size === 0 || [...pending.values()].every((c) => EARLY_EXIT_STATUSES[c.storedSessionStatus ?? ""] === true);
|
||||
if (allPendingTerminal) break;
|
||||
}
|
||||
firstPass = false;
|
||||
if (Date.now() < deadline) {
|
||||
@@ -173,8 +173,8 @@ rigCmuxRoutes.post("/launch", async (c) => {
|
||||
|
||||
// Classify remaining pending/no-session with reason fidelity.
|
||||
for (const [logicalId, candidate] of pending) {
|
||||
const isStale = STALE_STATUSES.has(candidate.sessionStatus ?? "");
|
||||
missing.push({ logicalId, reason: isStale ? "session-missing" : "still-booting" });
|
||||
const isMissing = MISSING_SESSION_STATUSES[candidate.sessionStatus ?? ""] === true;
|
||||
missing.push({ logicalId, reason: isMissing ? "session-missing" : "still-booting" });
|
||||
}
|
||||
for (const logicalId of noSessionIds) {
|
||||
missing.push({ logicalId, reason: "no-session" });
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { AgentActivityStore } from "../domain/agent-activity-store.js";
|
||||
import type { SeatActivityService } from "../domain/seat-activity-service.js";
|
||||
import type { SeatStructuralActivityService } from "../domain/seat-structural-activity-service.js";
|
||||
import { deriveRigLifecycleState } from "../domain/ps-projection.js";
|
||||
import { identityVerdictConfirmsSessionMissing } from "../domain/types.js";
|
||||
import { assessCurrentStateRehydrateEligibility, snapshotMatchesCurrentOccupants } from "../domain/rehydrate-eligibility.js";
|
||||
import { buildRestorePlanPreview, collectPreviewSessionRows } from "../domain/restore-plan-preview.js";
|
||||
import { readFreshOccupantRelations } from "../domain/fresh-occupant-relation.js";
|
||||
@@ -478,10 +479,13 @@ rigsRoutes.post("/:id/archive", async (c) => {
|
||||
const body: Record<string, unknown> = await c.req.json().catch(() => ({}));
|
||||
const force = body["force"] === true;
|
||||
|
||||
// AC-6 running-rig guard (daemon-layer, so every client inherits it): a
|
||||
// running/degraded rig requires --force, with a 3-part honest error.
|
||||
const inventory = getNodeInventory(repo.db, rigId);
|
||||
const lifecycleState = deriveRigLifecycleState(inventory.map((e) => e.lifecycleState));
|
||||
// Preserve main's action decision: only a stored running session with
|
||||
// confirmed absence was attention_required before this read projection.
|
||||
const lifecycleState = deriveRigLifecycleState(getNodeInventory(repo.db, rigId).map((entry) =>
|
||||
entry.storedSessionStatus === "running" && identityVerdictConfirmsSessionMissing(entry.identityVerdict)
|
||||
? "attention_required"
|
||||
: entry.lifecycleState
|
||||
));
|
||||
if ((lifecycleState === "running" || lifecycleState === "degraded") && !force) {
|
||||
return c.json({
|
||||
error: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, it, expect } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { ulid } from "ulid";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { getNodeInventory, getNodeDetail, deriveNodeLifecycleState } from "../src/domain/node-inventory.js";
|
||||
import { getNodeInventory, getNodeInventoryForAllRigs, getNodeInventoryForRigs, getNodeDetail, deriveNodeLifecycleState, attachAgentActivity, attachTerminalActivityAndWork } from "../src/domain/node-inventory.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
import { projectRigToGraph, type InventoryOverlay } from "../src/domain/graph-projection.js";
|
||||
import type { SeatIdentityVerdict, SeatIdentityVerdictKind } from "../src/domain/types.js";
|
||||
@@ -126,12 +126,27 @@ describe("getNodeInventory identity gating", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("PANE_MISSING verdict → NOT clean running; evidence names it missing", () => {
|
||||
it("confirmed missing session projects detached without rewriting history or stored occupant state", () => {
|
||||
const db = createFullTestDb();
|
||||
seedRunningSeat(db);
|
||||
db.prepare("UPDATE nodes SET occupant_lifecycle = 'active' WHERE id = 'n1'").run();
|
||||
const stored = db.prepare("SELECT * FROM sessions WHERE id = 'sess1'").get();
|
||||
new SeatIdentityStore(db).upsert(verdict("pane_missing", "session_missing"));
|
||||
const [n] = getNodeInventory(db, "rig-1");
|
||||
expect(n.lifecycleState).toBe("attention_required");
|
||||
expect(n.lifecycleState).toBe("detached");
|
||||
expect(n.sessionStatus).toBe("detached");
|
||||
expect(n.storedSessionStatus).toBe("running");
|
||||
expect(n.startupStatus).toBe("ready");
|
||||
expect(n.storedStartupStatus).toBe("ready");
|
||||
expect(getNodeDetail(db, "rig-1", "dev.impl")).toMatchObject({
|
||||
sessionStatus: "detached", storedSessionStatus: "running",
|
||||
lifecycleState: "detached", occupantLifecycle: "unknown",
|
||||
identityVerdict: n.identityVerdict,
|
||||
});
|
||||
expect(getNodeInventoryForAllRigs(db).get("rig-1")).toEqual([n]);
|
||||
expect(getNodeInventoryForRigs(db, new Set(["rig-1"])).get("rig-1")).toEqual([n]);
|
||||
expect(db.prepare("SELECT * FROM sessions WHERE id = 'sess1'").get()).toEqual(stored);
|
||||
expect(db.prepare("SELECT occupant_lifecycle FROM nodes WHERE id = 'n1'").get()).toEqual({ occupant_lifecycle: "active" });
|
||||
expect(n.occupantLifecycle).toBe("unknown");
|
||||
expect(n.identityVerdict?.reason).toBe("session_missing");
|
||||
db.close();
|
||||
@@ -153,7 +168,7 @@ describe("getNodeInventory identity gating", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("matching NULL-pane session_missing verdict down-ranks to attention_required", () => {
|
||||
it("matching NULL-pane session_missing verdict projects detached", () => {
|
||||
const db = createFullTestDb();
|
||||
seedRunningSeat(db);
|
||||
db.prepare("UPDATE bindings SET tmux_pane = NULL WHERE node_id = 'n1'").run();
|
||||
@@ -164,7 +179,8 @@ describe("getNodeInventory identity gating", () => {
|
||||
});
|
||||
const [n] = getNodeInventory(db, "rig-1");
|
||||
expect(n.identityVerdict?.verdict).toBe("pane_missing");
|
||||
expect(n.lifecycleState).toBe("attention_required");
|
||||
expect(n.lifecycleState).toBe("detached");
|
||||
expect(n.sessionStatus).toBe("detached");
|
||||
expect(n.occupantLifecycle).toBe("unknown");
|
||||
db.close();
|
||||
});
|
||||
@@ -182,9 +198,68 @@ describe("getNodeInventory identity gating", () => {
|
||||
expect(n.lifecycleState).toBe("attention_required");
|
||||
db.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["tmux_unavailable", "tmux_unavailable"],
|
||||
["pane_missing", "pane_pid_gone"],
|
||||
["mismatch", "process_identity_mismatch"],
|
||||
] as const)("%s/%s does not prove the session missing", (kind, reason) => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedRunningSeat(db);
|
||||
new SeatIdentityStore(db).upsert(verdict(kind, reason));
|
||||
const [entry] = getNodeInventory(db, "rig-1");
|
||||
expect(entry.sessionStatus).toBe("running");
|
||||
expect(entry.storedSessionStatus).toBe("running");
|
||||
if (kind === "tmux_unavailable") expect(entry.lifecycleState).toBe("running");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it("confirmed absence outranks fresh hook, structural, motion and taxonomy caches but retains assigned work", async () => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedRunningSeat(db);
|
||||
new SeatIdentityStore(db).upsert(verdict("pane_missing", "session_missing"));
|
||||
db.prepare("INSERT INTO queue_items (qitem_id, ts_created, ts_updated, source_session, destination_session, state, body) VALUES ('q1', '2026-07-02', '2026-07-02', 'op@rig', 'dev-impl@rig', 'pending', 'work')").run();
|
||||
const seatActivity = {
|
||||
getSeatActivity: () => ({ isActiveWithinWindow: true, lastActivityAt: "2026-07-02T12:00:00.000Z", silenceWindowSeconds: 60 }),
|
||||
getSeatStateBySession: () => ({ activity: "working", needsInput: { count: 0, reason: null } }),
|
||||
};
|
||||
const entries = await attachAgentActivity(getNodeInventory(db, "rig-1"), {
|
||||
tmuxAdapter: { capturePaneContent: () => { throw new Error("missing session must not be captured"); } } as never,
|
||||
activityStore: { getLatestForNode: () => ({ state: "running", reason: "hook" }) } as never,
|
||||
structuralActivity: { getStructuralActivity: () => ({ state: "mid_work" }) } as never,
|
||||
seatActivity: seatActivity as never,
|
||||
captureFallback: true,
|
||||
now: new Date("2026-07-02T12:00:01.000Z"),
|
||||
});
|
||||
const [entry] = attachTerminalActivityAndWork(entries, { db, seatActivity: seatActivity as never });
|
||||
expect(entry).toMatchObject({
|
||||
sessionStatus: "detached", terminalActive: false, activityState: null, lastActivityAt: null,
|
||||
hasAssignedWork: true, pendingWorkCount: 1,
|
||||
agentActivity: { state: "unknown", reason: "session_missing", evidenceSource: "tmux_session" },
|
||||
});
|
||||
} finally { db.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodeInventory verdict applicability gate (rev1-r2 B1 — no stale false-green)", () => {
|
||||
it.each(["session", "pane", "registration", "handover", "ulid"])("stale missing verdict cannot detach a current occupant after %s changes", changed => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
seedRunningSeat(db);
|
||||
new SeatIdentityStore(db).upsert(verdict("pane_missing", "session_missing"));
|
||||
if (changed === "session") db.prepare("UPDATE sessions SET session_name = 'new-seat@rig' WHERE id = 'sess1'").run();
|
||||
if (changed === "pane") db.prepare("UPDATE bindings SET tmux_pane = '%2' WHERE node_id = 'n1'").run();
|
||||
if (changed === "registration") db.prepare("UPDATE sessions SET created_at = '2026-07-02 12:00:01' WHERE id = 'sess1'").run();
|
||||
if (changed === "handover") db.prepare("UPDATE nodes SET handover_at = '2026-07-02T12:00:00.000Z' WHERE id = 'n1'").run();
|
||||
if (changed === "ulid") db.prepare("UPDATE sessions SET id = ? WHERE id = 'sess1'").run(ulid(Date.parse("2026-07-02T12:00:00.000Z")));
|
||||
expect(getNodeInventory(db, "rig-1")[0]).toMatchObject({
|
||||
identityVerdict: null, sessionStatus: "running", lifecycleState: "running", occupantLifecycle: "active",
|
||||
});
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
it.each(["retained", "finishes after cutover"])("same-pane successor rejects predecessor evidence: %s", timing => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
@@ -241,7 +316,7 @@ describe("getNodeInventory verdict applicability gate (rev1-r2 B1 — no stale f
|
||||
store.upsert(verdict("mismatch"));
|
||||
expect(getNodeInventory(db, "rig-1")[0].identityVerdict).toBeNull();
|
||||
store.upsert({ ...verdict("pane_missing", "session_missing"), observedAt: "2026-07-02T12:00:00.001Z" });
|
||||
expect(getNodeInventory(db, "rig-1")[0].startupStatus).toBe("attention_required");
|
||||
expect(getNodeInventory(db, "rig-1")[0].sessionStatus).toBe("detached");
|
||||
} finally { db.close(); }
|
||||
});
|
||||
|
||||
@@ -334,7 +409,7 @@ describe("getNodeInventory verdict applicability gate (rev1-r2 B1 — no stale f
|
||||
new SeatIdentityStore(db).upsert(verdict("pane_missing", "session_missing"));
|
||||
const [n] = getNodeInventory(db, "rig-1");
|
||||
expect(n.identityVerdict?.reason).toBe("session_missing");
|
||||
expect(n.lifecycleState).toBe("attention_required");
|
||||
expect(n.lifecycleState).toBe("detached");
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -680,16 +680,12 @@ describe("PsProjectionService", () => {
|
||||
|
||||
// ===================================================================
|
||||
// SLICE-05 item-5 (D5) — ps must not fabricate a dead seat as running.
|
||||
// runningCount/status are raw `sessions.status='running'` SQL (verdict-blind);
|
||||
// the SeatIdentityReconciler writes a session_missing verdict to a separate table
|
||||
// that runningCount never reads. After an out-of-band tmux teardown the persisted
|
||||
// status stays 'running', so ps fabricates running. RED pins that effective
|
||||
// runningCount/status must exclude a seat with a session_missing identity verdict.
|
||||
// Production seam UNDECIDED (ps consumes verdict, or adapter/verdict demotion) —
|
||||
// this asserts the OUTCOME, not the mechanism. self-contained (own db).
|
||||
// The persisted status stays running after an out-of-band tmux teardown.
|
||||
// Effective counts and lifecycle must honor the applicable absence verdict
|
||||
// while keeping the registration intact. Self-contained (own db).
|
||||
// ===================================================================
|
||||
describe("Slice-05 item-5 (D5) — ps running honesty vs identity verdict", () => {
|
||||
it("RED: a running session with an APPLICABLE session_missing verdict is NOT counted running", () => {
|
||||
it("a running session with an applicable session_missing verdict is not counted running", () => {
|
||||
const db = createFullTestDb();
|
||||
try {
|
||||
db.prepare("INSERT INTO rigs (id, name) VALUES ('r-d5','d5')").run();
|
||||
@@ -713,11 +709,11 @@ describe("Slice-05 item-5 (D5) — ps running honesty vs identity verdict", () =
|
||||
observedAt: "2026-07-02T12:00:00.000Z",
|
||||
});
|
||||
const entry = new PsProjectionService({ db }).getEntries()[0]!;
|
||||
// The verdict IS applicable: the lifecycle axis already honors it (down-ranked).
|
||||
expect(entry.lifecycleState).toBe("attention_required");
|
||||
// ...but the running axis is verdict-blind — the exact inconsistency this RED pins.
|
||||
expect(entry.runningCount).toBe(0); // <-- RED: currently 1 (raw sessions.status, verdict-blind)
|
||||
expect(entry.status).not.toBe("running"); // <-- RED: currently "running"
|
||||
expect(entry.lifecycleState).toBe("stopped");
|
||||
expect(entry.runningCount).toBe(0);
|
||||
expect(entry.activeCount).toBe(0);
|
||||
expect(entry.status).toBe("stopped");
|
||||
expect(db.prepare("SELECT status FROM sessions WHERE id = 's-d5'").get()).toEqual({ status: "running" });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface FakeRigOpts {
|
||||
podId: string | null;
|
||||
canonicalSessionName: string | null;
|
||||
sessionStatus?: string | null;
|
||||
storedSessionStatus?: string | null;
|
||||
attachmentType?: string | null;
|
||||
}>;
|
||||
}
|
||||
@@ -59,6 +60,7 @@ function makeNodeInventoryStub(rigs: Record<string, FakeRigOpts>) {
|
||||
logicalId: n.logicalId,
|
||||
canonicalSessionName: n.canonicalSessionName,
|
||||
sessionStatus: n.sessionStatus ?? (n.canonicalSessionName ? "running" : null),
|
||||
storedSessionStatus: n.storedSessionStatus ?? n.sessionStatus ?? (n.canonicalSessionName ? "running" : null),
|
||||
attachmentType: n.attachmentType ?? "tmux",
|
||||
podId: n.podId,
|
||||
})) as never;
|
||||
@@ -673,6 +675,75 @@ describe("POST /api/rigs/:rigId/cmux/launch", () => {
|
||||
expect(body.missing).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the bounded wait when a projected-detached session reappears", async () => {
|
||||
let callCount = 0;
|
||||
const dynamicTmux = {
|
||||
...makeTmuxAdapterStub(new Set()),
|
||||
hasSession: async () => ++callCount >= 3,
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const app = new Hono();
|
||||
const rigs = {
|
||||
"rig-1": {
|
||||
id: "rig-1",
|
||||
name: "returning-rig",
|
||||
nodes: [{ logicalId: "a", podId: "p1", canonicalSessionName: "a@returning-rig", sessionStatus: "detached" as string | undefined, storedSessionStatus: "running" }],
|
||||
},
|
||||
};
|
||||
const rigRepo = makeRigRepoStub(rigs);
|
||||
const layoutService = new CmuxLayoutService(makeMockAdapter({ available: true }), { sleep: async () => {} });
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("rigRepo" as never, rigRepo);
|
||||
c.set("cmuxAdapter" as never, makeMockAdapter({ available: true }));
|
||||
c.set("cmuxLayoutService" as never, layoutService);
|
||||
c.set("nodeInventoryFn" as never, makeNodeInventoryStub(rigs));
|
||||
c.set("tmuxAdapter" as never, dynamicTmux);
|
||||
c.set("readinessTimeoutMs" as never, 500);
|
||||
c.set("readinessPollMs" as never, 10);
|
||||
c.set("db" as never, {} as Database.Database);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
|
||||
|
||||
const res = await app.request("/api/rigs/rig-1/cmux/launch", { method: "POST" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { workspaces: Array<{ agents: string[] }> };
|
||||
expect(body.workspaces[0]!.agents).toContain("a@returning-rig");
|
||||
});
|
||||
|
||||
it("stops early when both projected and stored status are detached", async () => {
|
||||
let callCount = 0;
|
||||
const dynamicTmux = {
|
||||
...makeTmuxAdapterStub(new Set()),
|
||||
hasSession: async () => { callCount++; return false; },
|
||||
} as unknown as import("../src/adapters/tmux.js").TmuxAdapter;
|
||||
const app = new Hono();
|
||||
const rigs = {
|
||||
"rig-1": {
|
||||
id: "rig-1",
|
||||
name: "stored-detached-rig",
|
||||
nodes: [{ logicalId: "a", podId: "p1", canonicalSessionName: "a@stored-detached-rig", sessionStatus: "detached" as string | undefined, storedSessionStatus: "detached" }],
|
||||
},
|
||||
};
|
||||
const rigRepo = makeRigRepoStub(rigs);
|
||||
const layoutService = new CmuxLayoutService(makeMockAdapter({ available: true }), { sleep: async () => {} });
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("rigRepo" as never, rigRepo);
|
||||
c.set("cmuxAdapter" as never, makeMockAdapter({ available: true }));
|
||||
c.set("cmuxLayoutService" as never, layoutService);
|
||||
c.set("nodeInventoryFn" as never, makeNodeInventoryStub(rigs));
|
||||
c.set("tmuxAdapter" as never, dynamicTmux);
|
||||
c.set("readinessTimeoutMs" as never, 500);
|
||||
c.set("readinessPollMs" as never, 10);
|
||||
c.set("db" as never, {} as Database.Database);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/rigs/:rigId/cmux", rigCmuxRoutes);
|
||||
|
||||
const res = await app.request("/api/rigs/rig-1/cmux/launch", { method: "POST" });
|
||||
expect(res.status).toBe(412);
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("OPR.0.3.4.8: stale/exited session remains session-missing (never attached even after polling)", async () => {
|
||||
const app = buildApp({
|
||||
rigs: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { SnapshotRepository } from "../src/domain/snapshot-repository.js";
|
||||
import type { SnapshotCapture } from "../src/domain/snapshot-capture.js";
|
||||
import type { RestoreOrchestrator } from "../src/domain/restore-orchestrator.js";
|
||||
import { createFullTestDb, createTestApp } from "./helpers/test-app.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
|
||||
function insertStartupContextRow(db: Database.Database, nodeId: string) {
|
||||
db.prepare(
|
||||
@@ -158,6 +159,61 @@ describe("Rig CRUD routes", () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("archives a mixed healthy-plus-missing rig without force, as on main", async () => {
|
||||
const rig = repo.createRig("mixed-archive");
|
||||
const healthy = repo.addNode(rig.id, "dev.healthy", { runtime: "codex" });
|
||||
const missing = repo.addNode(rig.id, "dev.missing", { runtime: "codex" });
|
||||
const healthySession = sessionRegistry.registerSession(healthy.id, "dev-healthy@mixed-archive");
|
||||
const missingSession = sessionRegistry.registerSession(missing.id, "dev-missing@mixed-archive");
|
||||
for (const session of [healthySession, missingSession]) sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(missing.id, {
|
||||
tmuxSession: missingSession.sessionName, tmuxPane: "%1", attachmentType: "tmux",
|
||||
});
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: missing.id,
|
||||
sessionName: missingSession.sessionName,
|
||||
verdict: "pane_missing",
|
||||
evidenceSource: "tmux_session",
|
||||
reason: "session_missing",
|
||||
evidence: { registeredPane: "%1", observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
observedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/rigs/${rig.id}/archive`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(repo.getRigSummaries({ archivedOnly: true }).map((entry) => entry.id)).toContain(rig.id);
|
||||
});
|
||||
|
||||
it("requires force for a live seat plus stored-detached missing seat", async () => {
|
||||
const rig = repo.createRig("stored-detached-archive");
|
||||
const healthy = repo.addNode(rig.id, "dev.healthy", { runtime: "codex" });
|
||||
const missing = repo.addNode(rig.id, "dev.missing", { runtime: "codex" });
|
||||
const healthySession = sessionRegistry.registerSession(healthy.id, "dev-healthy@stored-detached-archive");
|
||||
const missingSession = sessionRegistry.registerSession(missing.id, "dev-missing@stored-detached-archive");
|
||||
sessionRegistry.updateStatus(healthySession.id, "running");
|
||||
sessionRegistry.updateStatus(missingSession.id, "detached");
|
||||
sessionRegistry.updateBinding(missing.id, {
|
||||
tmuxSession: missingSession.sessionName, tmuxPane: "%1", attachmentType: "tmux",
|
||||
});
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: missing.id, sessionName: missingSession.sessionName,
|
||||
verdict: "pane_missing", evidenceSource: "tmux_session", reason: "session_missing",
|
||||
evidence: { registeredPane: "%1", observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
observedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
|
||||
const archive = (force: boolean) => app.request(`/api/rigs/${rig.id}/archive`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ force }),
|
||||
});
|
||||
expect((await archive(false)).status).toBe(409);
|
||||
expect((await archive(true)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("POST /api/rigs/:id/attach-self binds an external_cli agent to an existing node", async () => {
|
||||
const rig = repo.createRig("rigged-buildout");
|
||||
const node = repo.addNode(rig.id, "orch1.lead", { runtime: "claude-code" });
|
||||
|
||||
@@ -229,7 +229,12 @@ describe("SeatAttentionReconciler", () => {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.detail).toContain("Uncleared attention class pane_identity");
|
||||
expect(sessionRegistry.getBindingForNode(node.id)?.tmuxPane).toBe("%old");
|
||||
expect(getNodeInventory(db, rig.id)[0]!.lifecycleState).toBe("attention_required");
|
||||
// Re-observation confirmed the whole session missing, rather than just
|
||||
// the registered pane process being gone. Preserve that stronger fact.
|
||||
expect(getNodeInventory(db, rig.id)[0]!).toMatchObject({
|
||||
lifecycleState: "detached", sessionStatus: "detached",
|
||||
storedSessionStatus: "running", identityVerdict: { reason: "session_missing" },
|
||||
});
|
||||
});
|
||||
|
||||
// Audit event emitted with distinct clearedBy
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { SeatStatusService } from "../src/domain/seat-status-service.js";
|
||||
import { PodRepository } from "../src/domain/pod-repository.js";
|
||||
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
|
||||
|
||||
describe("SeatStatusService", () => {
|
||||
let db: Database.Database;
|
||||
@@ -23,6 +24,30 @@ describe("SeatStatusService", () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("reports a confirmed missing current session as detached while preserving its registration", async () => {
|
||||
const { app, rigRepo: repo, sessionRegistry: registry } = createTestApp(db);
|
||||
const rig = repo.createRig("missing-seat");
|
||||
const node = repo.addNode(rig.id, "dev.impl", { runtime: "codex" });
|
||||
const session = registry.registerSession(node.id, "dev-impl@missing-seat");
|
||||
registry.updateStatus(session.id, "running");
|
||||
registry.updateStartupStatus(session.id, "ready");
|
||||
registry.updateBinding(node.id, { tmuxSession: session.sessionName, tmuxPane: "%1", attachmentType: "tmux" });
|
||||
const stored = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id);
|
||||
new SeatIdentityStore(db).upsert({
|
||||
nodeId: node.id, sessionName: session.sessionName,
|
||||
verdict: "pane_missing", reason: "session_missing", evidenceSource: "tmux_session",
|
||||
evidence: { registeredPane: "%1", observedPid: null, observedCommand: null, matchedLayer: null },
|
||||
observedAt: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
const res = await app.request(`/api/seat/status/${encodeURIComponent(session.sessionName)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
session_status: "detached", session_reason: "session_missing", startup_status: "ready",
|
||||
occupant_lifecycle: "unknown", current_occupant: session.sessionName,
|
||||
});
|
||||
expect(db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id)).toEqual(stored);
|
||||
});
|
||||
|
||||
it("returns honest no-handover defaults for an existing active node", () => {
|
||||
const rig = rigRepo.createRig("seat-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "codex", cwd: "/project" });
|
||||
|
||||
@@ -59,7 +59,10 @@ describe.each(["capture", "send"] as const)("%s absence observation", verb => {
|
||||
const stored = new SeatIdentityStore(db).getForNode("n");
|
||||
if (change === "current occupant") {
|
||||
expect(stored).toMatchObject({ reason: "session_missing", observedAt: new Date(start + 100).toISOString(), evidence: { registeredPane: "%1" } });
|
||||
expect(getNodeInventory(db, "r")[0]!.startupStatus).toBe("attention_required");
|
||||
expect(getNodeInventory(db, "r")[0]!).toMatchObject({
|
||||
sessionStatus: "detached", storedSessionStatus: "running",
|
||||
startupStatus: "ready", lifecycleState: "detached",
|
||||
});
|
||||
} else {
|
||||
expect(stored).toMatchObject({ verdict: "verified", observedAt: new Date(start + 250).toISOString() });
|
||||
expect(getNodeInventory(db, "r")[0]!.startupStatus).toBe("ready");
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("Session routes", () => {
|
||||
expect(body[0].sessionName).toBe("r01-dev1-impl");
|
||||
});
|
||||
|
||||
|
||||
it("POST clear-attention re-scopes a legacy attempt-zero reconciliation to the real restore attempt", async () => {
|
||||
const tmux = {
|
||||
...mockTmuxAdapter(),
|
||||
|
||||
@@ -74,7 +74,7 @@ interface NodeInventoryRead {
|
||||
needsInput?: { count?: number; reason?: string | null } | null;
|
||||
decidedBy?: string | null;
|
||||
} | null;
|
||||
identityVerdict?: { verdict?: string } | null;
|
||||
identityVerdict?: { verdict?: string; reason?: string | null } | null;
|
||||
canonicalSessionName: string | null;
|
||||
tmuxAttachCommand?: string | null;
|
||||
cwd?: string | null;
|
||||
@@ -214,7 +214,7 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
const ctx = node.contextUsage;
|
||||
const known = ctx?.availability === "known";
|
||||
const identityDownranked = node.identityVerdict?.verdict === "mismatch"
|
||||
|| node.identityVerdict?.verdict === "pane_missing";
|
||||
|| (node.identityVerdict?.verdict === "pane_missing" && node.identityVerdict?.reason !== "session_missing");
|
||||
return {
|
||||
nodeId: node.nodeId,
|
||||
name: node.logicalId,
|
||||
@@ -247,8 +247,10 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
|
||||
},
|
||||
// Mirror the maintained web projection: lifecycle truth drives actions,
|
||||
// while session/terminal activity drives the visible status label.
|
||||
status: node.startupStatus === "failed"
|
||||
? "failed"
|
||||
status: node.identityVerdict?.reason === "session_missing" && node.sessionStatus === "detached" && node.lifecycleState !== "attention_required"
|
||||
? "detached"
|
||||
: node.startupStatus === "failed"
|
||||
? "failed"
|
||||
: node.lifecycleState === "attention_required" || identityDownranked || node.startupStatus === "attention_required"
|
||||
? "attention_required"
|
||||
// S19: the SERVED taxonomy display decides first (the daemon's one bridge);
|
||||
|
||||
@@ -407,6 +407,7 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
|
||||
it("gives failed, attention, and needs-input truth precedence over terminal active/idle", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
|
||||
const nodes = [
|
||||
{ ...base, logicalId: "dev.failed", startupStatus: "failed", lifecycleState: "attention_required", terminalActive: true },
|
||||
{ ...base, logicalId: "dev.attention", startupStatus: "attention_required", lifecycleState: "attention_required", terminalActive: false },
|
||||
@@ -434,6 +435,35 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
|
||||
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
|
||||
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
|
||||
});
|
||||
it("shows confirmed missing sessions as detached unless lifecycle still requires attention", async () => {
|
||||
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
|
||||
const nodes = [
|
||||
{
|
||||
...base,
|
||||
logicalId: "dev.missing-session",
|
||||
sessionStatus: "detached",
|
||||
startupStatus: "failed",
|
||||
lifecycleState: "detached",
|
||||
terminalActive: false,
|
||||
identityVerdict: { verdict: "pane_missing", reason: "session_missing" },
|
||||
},
|
||||
{
|
||||
...base,
|
||||
logicalId: "dev.missing-attention",
|
||||
sessionStatus: "detached",
|
||||
startupStatus: "attention_required",
|
||||
lifecycleState: "attention_required",
|
||||
terminalActive: false,
|
||||
identityVerdict: { verdict: "pane_missing", reason: "session_missing" },
|
||||
},
|
||||
];
|
||||
const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes }));
|
||||
const statuses = Object.fromEntries(snap.hosts[0]!.rigs[0]!.pods[0]!.agents.map((agent) => [agent.name, agent.status]));
|
||||
expect(statuses).toEqual({
|
||||
"dev.missing-session": "detached",
|
||||
"dev.missing-attention": "attention_required",
|
||||
});
|
||||
});
|
||||
|
||||
it("joins Specs↔Topology over existing reads: rig agentRefs + agent usedByRigs", async () => {
|
||||
const snap = await hydrateSnapshot(fixtureClient());
|
||||
|
||||
Reference in new issue
Block a user