Fix compaction recovery and transcript ingest honesty

This commit is contained in:
v-openrig-build committed 2026-08-02 09:33:08 +00:00
1 parent 5f3b5bd497
commit ca1a14e514
18 files changed
+424 -74

No files matched your search

@@ -71,8 +71,13 @@ restore-needs evolve.)
2. Read this file's §1 + your §A-§X section + this §9
3. Read `missions/{{mission_id}}/README.md` for mission scope
4. Read `missions/{{mission_id}}/PROGRESS.md` for delivery state
5. `rig queue list --destination <your-session>` — your durable inbox
6. State "restored from {{mission_id}}; resumed at <action>" before acting
5. `rig queue list --mine --full --json` — triage your durable inbox from full
bodies; use `rig queue show <id> --full --json` for individual items. Never
use the bare compact queue list for triage.
6. Optionally check `rig transcript <your-session> --tail 100` as secondary
evidence. It may be unavailable or incomplete for some runtimes; little or
no output does not prove the session was quiet.
7. State "restored from {{mission_id}}; resumed at <action>" before acting
## §10. What NOT to reconstruct
@@ -50,7 +50,12 @@ Broadcast to all peers in your rig:
rig broadcast --rig <name> "message"
```
## Transcript Recovery
## Secondary Transcript Check
After compaction, recover work from durable artifacts first: the restore map,
current `MISSION_NOTES.md`, and your owned queue items. Transcript output is a
secondary check and may be unavailable or incomplete for some runtimes. Little
or no output does not mean the session was quiet.
Read recent transcript output:
```bash
@@ -64,4 +69,8 @@ rig transcript <session> --grep "pattern"
## After Compaction
If you lose context, run `rig whoami --json` immediately. It tells you who you are, who your peers are, and how to reach them. Then use `rig transcript` to recover recent history.
If you lose context, run `rig whoami --json` immediately. It tells you who you
are, who your peers are, and how to reach them. Rebuild the work from the
restore map, current `MISSION_NOTES.md`, and your owned queue items. Use
`rig transcript` only as a secondary check; if it returns little or nothing,
do not conclude that the session was quiet.
@@ -67,7 +67,10 @@ When a seat's startup is artifact-backed mental-model rebuild (active-work
reentry case, distinct from reusable Agent Starters / priming packs):
- See the **cross-runtime restore/reentry packet standard v0**
- Source-trust ranking applies: **`rig whoami` > target rigspec > bounded latest transcript > full transcript > touched-files > `restore-summary.json`**
- Source-trust ranking applies: **`rig whoami` > target rigspec > restore map /
current `MISSION_NOTES.md` / owned queue items > touched files > transcript
output**. Transcript output is secondary and may be unavailable or incomplete;
little or no output does not prove the session was quiet.
## Memory surfaces consumed at startup
@@ -230,8 +230,10 @@ If you're booting into a new seat in an OpenRig rig:
that tells you when to reach for it.
5. **Check your peers** — `rig capture <peer-session>` to see what they're
doing.
6. **Check the transcripts** if you're returning to an in-flight workstream
— `rig transcript <session> --tail 100` for recent context.
6. **Recover from durable artifacts** if you're returning to an in-flight
workstream: read the restore map, current `MISSION_NOTES.md`, and owned queue
items. Use `rig transcript <session> --tail 100` only as a secondary check;
little or no output does not prove the workstream was quiet.
7. **Ask `rig ask <rig> "<question>"`** if you need cross-cutting evidence
from the rig's transcripts and chat.
@@ -47,6 +47,7 @@ export class ContextMonitor {
private compactionEnforcer: ClaudeCompactionEnforcer | null;
private readinessCheckers: Record<string, RuntimeReadinessChecker | undefined>;
private timer: ReturnType<typeof setInterval> | null = null;
private activePoll: Promise<void> | null = null;
constructor(
db: Database.Database,
@@ -67,6 +68,17 @@ export class ContextMonitor {
/** Discover active managed Claude sessions and poll their sidecar files. */
async pollOnce(): Promise<void> {
if (this.activePoll) return this.activePoll;
const poll = this.runPoll();
this.activePoll = poll;
try {
await poll;
} finally {
if (this.activePoll === poll) this.activePoll = null;
}
}
private async runPoll(): Promise<void> {
const sessions = this.getEligibleSessions();
for (const session of sessions) {
let observed: ContextUsage | null = null;
+13 -1
View File
@@ -5,6 +5,7 @@ import { SeatIdentityStore } from "./seat-identity-store.js";
import { buildOrientedMap } from "./startup-proof.js";
import type { RuntimeAdapter } from "./runtime-adapter.js";
import type { ContextUsageStore } from "./context-usage-store.js";
import type { TranscriptStore } from "./transcript-store.js";
import type { AgentActivityStore } from "./agent-activity-store.js";
import type { SeatActivityService } from "./seat-activity-service.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
@@ -877,6 +878,7 @@ export function getNodeInventoryWithContext(
db: Database.Database,
rigId: string,
contextUsageStore: ContextUsageStore,
transcriptStore?: Pick<TranscriptStore, "getIngestHealth">,
): NodeInventoryEntry[] {
const entries = getNodeInventory(db, rigId);
@@ -901,7 +903,17 @@ export function getNodeInventoryWithContext(
// large fleet; no node-list consumer reads it). All scalars are kept so
// the ring/table/filter consumers are unaffected. The full currentUsage
// remains on the detail/whoami path (getNodeDetailWithContext, whoami).
return { ...e, contextUsage: { ...usage, currentUsage: null } };
const transcriptIngest = transcriptStore && e.canonicalSessionName
? {
...transcriptStore.getIngestHealth(e.rigName, e.canonicalSessionName),
runtime: e.runtime,
}
: undefined;
return {
...e,
contextUsage: { ...usage, currentUsage: null },
...(transcriptIngest ? { transcriptIngest } : {}),
};
});
}
@@ -1,3 +1,4 @@
import type Database from "better-sqlite3";
import type { TmuxAdapter } from "../adapters/tmux.js";
import type { TranscriptStore } from "./transcript-store.js";
import {
@@ -33,3 +34,45 @@ export async function startTmuxTranscriptCapture(
);
return { started: true };
}
interface RunningTranscriptSession {
rig_name: string;
session_name: string;
}
/** Restore process-local rotation timers after daemon restart. */
export async function resumeRunningTranscriptCaptures(
db: Database.Database,
tmuxAdapter: TmuxAdapter | null | undefined,
transcriptStore: TranscriptStore | null | undefined,
): Promise<number> {
if (!tmuxAdapter || !transcriptStore?.enabled) return 0;
const rows = db.prepare(`
SELECT r.name AS rig_name, s.session_name
FROM sessions s
JOIN nodes n ON n.id = s.node_id
JOIN rigs r ON r.id = n.rig_id
JOIN bindings b ON b.node_id = n.id
WHERE s.status = 'running'
AND s.id = (
SELECT s2.id FROM sessions s2
WHERE s2.node_id = n.id
ORDER BY s2.id DESC
LIMIT 1
)
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
AND b.tmux_session = s.session_name
`).all() as RunningTranscriptSession[];
let resumed = 0;
for (const row of rows) {
const result = await startTmuxTranscriptCapture(
tmuxAdapter,
transcriptStore,
row.rig_name,
row.session_name,
);
if (result.started) resumed += 1;
}
return resumed;
}
@@ -6,9 +6,19 @@ import { getCompatibleOpenRigPath } from "../openrig-compat.js";
export interface TranscriptStoreOpts {
transcriptsRoot?: string;
enabled?: boolean;
staleAfterMs?: number;
}
export type TranscriptIngestState = "live" | "degraded" | "unavailable";
export interface TranscriptIngestHealth {
state: TranscriptIngestState;
reason: "capture_fresh" | "capture_stale" | "capture_empty" | "capture_missing" | "capture_disabled" | "capture_unreadable";
lastCapturedAt: string | null;
}
const DEFAULT_ROOT = getCompatibleOpenRigPath("transcripts");
export const DEFAULT_TRANSCRIPT_STALE_AFTER_MS = 10_000;
function applyBackspaces(text: string): string {
const chars: string[] = [];
@@ -170,10 +180,12 @@ function countNewlines(s: string): number {
export class TranscriptStore {
private readonly root: string;
private readonly _enabled: boolean;
private readonly staleAfterMs: number;
constructor(opts?: TranscriptStoreOpts) {
this.root = opts?.transcriptsRoot ?? DEFAULT_ROOT;
this._enabled = opts?.enabled ?? true;
this.staleAfterMs = opts?.staleAfterMs ?? DEFAULT_TRANSCRIPT_STALE_AFTER_MS;
}
get enabled(): boolean {
@@ -189,6 +201,29 @@ export class TranscriptStore {
return resolved;
}
getIngestHealth(rigName: string, sessionName: string): TranscriptIngestHealth {
if (!this._enabled) {
return { state: "unavailable", reason: "capture_disabled", lastCapturedAt: null };
}
try {
const filePath = this.getTranscriptPath(rigName, sessionName);
if (!existsSync(filePath)) {
return { state: "unavailable", reason: "capture_missing", lastCapturedAt: null };
}
const stat = statSync(filePath);
const lastCapturedAt = stat.mtime.toISOString();
if (stat.size === 0) {
return { state: "degraded", reason: "capture_empty", lastCapturedAt };
}
if (Date.now() - stat.mtimeMs > this.staleAfterMs) {
return { state: "degraded", reason: "capture_stale", lastCapturedAt };
}
return { state: "live", reason: "capture_fresh", lastCapturedAt };
} catch {
return { state: "degraded", reason: "capture_unreadable", lastCapturedAt: null };
}
}
ensureTranscriptDir(rigName: string): boolean {
if (!this._enabled) return false;
try {
+4
View File
@@ -555,6 +555,10 @@ export interface NodeInventoryEntry {
startupCompletedAt: string | null;
agentActivity?: AgentActivity;
contextUsage?: ContextUsage;
/** Health of the daemon-owned transcript capture for this seat. */
transcriptIngest?: import("./transcript-store.js").TranscriptIngestHealth & {
runtime: string | null;
};
/**
* Slice 15 — `terminal-active` primitive (tmux byte-stream).
*
@@ -419,8 +419,13 @@ restore-needs evolve.)
2. Read this file's §1 + your §A-§X section + this §9
3. Read \`missions/{{mission_id}}/README.md\` for mission scope
4. Read \`missions/{{mission_id}}/PROGRESS.md\` for delivery state
5. \`rig queue list --destination <your-session>\` — your durable inbox
6. State "restored from {{mission_id}}; resumed at <action>" before acting
5. \`rig queue list --mine --full --json\` — triage your durable inbox from full
bodies; use \`rig queue show <id> --full --json\` for individual items. Never
use the bare compact queue list for triage.
6. Optionally check \`rig transcript <your-session> --tail 100\` as secondary
evidence. It may be unavailable or incomplete for some runtimes; little or
no output does not prove the session was quiet.
7. State "restored from {{mission_id}}; resumed at <action>" before acting
## §10. What NOT to reconstruct
+3 -1
View File
@@ -26,6 +26,7 @@ import { createRestoreCheckService } from "./restore-check.js";
import type { KernelBootTracker, KernelState } from "../domain/kernel-boot-tracker.js";
import type { RecoveryPlan } from "../domain/restore-check-service.js";
import type { ContextUsageStore } from "../domain/context-usage-store.js";
import type { TranscriptStore } from "../domain/transcript-store.js";
import type { Pod, ExpansionPodFragment } from "../domain/types.js";
import type { RigExpansionService } from "../domain/rig-expansion-service.js";
import type { PodRigInstantiator } from "../domain/rigspec-instantiator.js";
@@ -298,8 +299,9 @@ rigsRoutes.get("/:id/graph", async (c) => {
const sessions = getSessionRegistry(c).getSessionsForRig(rigId);
// Overlay inventory data for enriched graph fields.
const ctxStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
const inventory = ctxStore
? getNodeInventoryWithContext(getRepo(c).db, rigId, ctxStore)
? getNodeInventoryWithContext(getRepo(c).db, rigId, ctxStore, transcriptStore)
: getNodeInventory(getRepo(c).db, rigId);
// PL-019 item 4: enrich inventory with agentActivity at graph-payload time
+2 -1
View File
@@ -102,8 +102,9 @@ nodesRoutes.get("/", async (c) => {
}
const contextUsageStore = c.get("contextUsageStore" as never) as ContextUsageStore | undefined;
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore | undefined;
const inventory = contextUsageStore
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore)
? getNodeInventoryWithContext(deps.rigRepo.db, rigId, contextUsageStore, transcriptStore)
: getNodeInventory(deps.rigRepo.db, rigId);
// Slice 15 — enrich with the two new orthogonal primitives. Order is
// independent (each enrichment reads its own source), so the chain
+96 -57
View File
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import type Database from "better-sqlite3";
import type { RigRepository } from "../domain/rig-repository.js";
import type { TranscriptStore } from "../domain/transcript-store.js";
import type { TranscriptIngestHealth, TranscriptStore } from "../domain/transcript-store.js";
import type { TmuxAdapter } from "../adapters/tmux.js";
import { startTmuxTranscriptCapture } from "../domain/transcript-capture.js";
import { redactTranscriptContent } from "../domain/transcript-redaction.js";
@@ -12,6 +12,7 @@ interface SessionRow {
interface NodeRow {
rig_id: string;
runtime: string | null;
}
interface BindingRow {
@@ -25,7 +26,7 @@ function resolveSessionToRig(
db: Database.Database,
rigRepo: RigRepository,
sessionName: string,
): { rigName: string; nodeId: string } | { error: string; status: number } {
): { rigName: string; nodeId: string; runtime: string | null } | { error: string; status: number } {
// Find ALL sessions with this name to detect ambiguity
const sessionRows = db
.prepare("SELECT node_id FROM sessions WHERE session_name = ? ORDER BY id DESC")
@@ -42,7 +43,7 @@ function resolveSessionToRig(
const rigNames = new Set<string>();
for (const row of sessionRows) {
const nodeRow = db
.prepare("SELECT rig_id FROM nodes WHERE id = ?")
.prepare("SELECT rig_id, runtime FROM nodes WHERE id = ?")
.get(row.node_id) as NodeRow | undefined;
if (nodeRow) {
const rig = rigRepo.getRig(nodeRow.rig_id);
@@ -65,7 +66,14 @@ function resolveSessionToRig(
};
}
return { rigName: rigNames.values().next().value!, nodeId: sessionRows[0]!.node_id };
const selectedNode = db
.prepare("SELECT rig_id, runtime FROM nodes WHERE id = ?")
.get(sessionRows[0]!.node_id) as NodeRow;
return {
rigName: rigNames.values().next().value!,
nodeId: sessionRows[0]!.node_id,
runtime: selectedNode.runtime,
};
}
async function tryStartCaptureForSession(
@@ -86,14 +94,43 @@ async function tryStartCaptureForSession(
return result.started;
}
async function warmTranscriptTail(
type RouteIngestHealth = TranscriptIngestHealth & { runtime: string | null };
async function ensureTranscriptIngest(
db: Database.Database,
transcriptStore: TranscriptStore,
rigName: string,
tmuxAdapter: TmuxAdapter | undefined,
resolution: { rigName: string; nodeId: string; runtime: string | null },
sessionName: string,
lines: number,
): Promise<string | null> {
await new Promise((resolve) => setTimeout(resolve, CAPTURE_WARMUP_MS));
return transcriptStore.readTail(rigName, sessionName, lines);
): Promise<{ health: RouteIngestHealth; started: boolean }> {
let health = transcriptStore.getIngestHealth(resolution.rigName, sessionName);
let started = false;
if (health.state !== "live") {
started = await tryStartCaptureForSession(
db,
transcriptStore,
tmuxAdapter,
resolution.rigName,
resolution.nodeId,
sessionName,
);
if (started) {
await new Promise((resolve) => setTimeout(resolve, CAPTURE_WARMUP_MS));
health = transcriptStore.getIngestHealth(resolution.rigName, sessionName);
}
}
return { health: { ...health, runtime: resolution.runtime }, started };
}
function transcriptIngestError(sessionName: string, health: RouteIngestHealth, started = false): string {
const runtime = health.runtime ?? "unknown runtime";
if (health.reason === "capture_missing") {
if (started) {
return `No transcript for '${sessionName}' yet (${runtime}). Transcript capture was missing and has been started now. Retry after the session emits new output.`;
}
return `No transcript for '${sessionName}' (${runtime}; ingest unavailable: ${health.reason}). Transcripts start automatically on next rig up.`;
}
return `Transcript ingest degraded for '${sessionName}' (${runtime}; state=${health.state}; reason=${health.reason}). Do not conclude the session was quiet from this transcript.`;
}
export function transcriptRoutes(): Hono {
@@ -120,33 +157,31 @@ export function transcriptRoutes(): Hono {
return c.json({ error: resolution.error }, resolution.status as 404);
}
const content = transcriptStore.readTail(resolution.rigName, sessionName, lines);
if (content === null) {
const startedNow = await tryStartCaptureForSession(
db,
transcriptStore,
tmuxAdapter,
resolution.rigName,
resolution.nodeId,
sessionName,
);
if (startedNow) {
const warmedContent = await warmTranscriptTail(transcriptStore, resolution.rigName, sessionName, lines);
if (warmedContent !== null && warmedContent !== "") {
return c.json({ session: sessionName, lines, content: warmedContent });
}
}
const ingest = await ensureTranscriptIngest(
db, transcriptStore, tmuxAdapter, resolution, sessionName,
);
const ingestHealth = ingest.health;
if (ingestHealth.state !== "live") {
return c.json(
{
error: startedNow
? `No transcript for '${sessionName}' yet. Transcript capture was missing and has been started now. Retry after the session emits new output.`
: `No transcript for '${sessionName}'. Transcripts start automatically on next rig up.`,
},
404,
{ error: transcriptIngestError(sessionName, ingestHealth, ingest.started), ingestHealth },
ingestHealth.state === "unavailable" ? 404 : 503,
);
}
return c.json({ session: sessionName, lines, content });
const content = transcriptStore.readTail(resolution.rigName, sessionName, lines);
if (content === null || content === "") {
const emptyHealth: RouteIngestHealth = {
...ingestHealth,
state: "degraded",
reason: "capture_empty",
};
return c.json(
{ error: transcriptIngestError(sessionName, emptyHealth), ingestHealth: emptyHealth },
503,
);
}
return c.json({ session: sessionName, lines, content, ingestHealth });
});
router.get("/:session/grep", async (c) => {
@@ -183,27 +218,23 @@ export function transcriptRoutes(): Hono {
return c.json({ error: resolution.error }, resolution.status as 404);
}
const matches = transcriptStore.grep(resolution.rigName, sessionName, pattern);
if (matches === null) {
const startedNow = await tryStartCaptureForSession(
db,
transcriptStore,
tmuxAdapter,
resolution.rigName,
resolution.nodeId,
sessionName,
);
const ingest = await ensureTranscriptIngest(
db, transcriptStore, tmuxAdapter, resolution, sessionName,
);
const ingestHealth = ingest.health;
if (ingestHealth.state !== "live") {
return c.json(
{
error: startedNow
? `No transcript for '${sessionName}' yet. Transcript capture was missing and has been started now. Retry after the session emits new output.`
: `No transcript for '${sessionName}'. Transcripts start automatically on next rig up.`,
},
404,
{ error: transcriptIngestError(sessionName, ingestHealth, ingest.started), ingestHealth },
ingestHealth.state === "unavailable" ? 404 : 503,
);
}
return c.json({ session: sessionName, pattern, matches });
const matches = transcriptStore.grep(resolution.rigName, sessionName, pattern);
if (matches === null) {
return c.json({ error: transcriptIngestError(sessionName, ingestHealth), ingestHealth }, 503);
}
return c.json({ session: sessionName, pattern, matches, ingestHealth });
});
// GET /:session/full — return the full transcript content for a session.
@@ -223,6 +254,7 @@ export function transcriptRoutes(): Hono {
const transcriptStore = c.get("transcriptStore" as never) as TranscriptStore;
const db = c.get("db" as never) as Database.Database;
const rigRepo = c.get("rigRepo" as never) as RigRepository;
const tmuxAdapter = c.get("tmuxAdapter" as never) as TmuxAdapter | undefined;
const sessionName = c.req.param("session");
if (!transcriptStore?.enabled) {
@@ -237,21 +269,28 @@ export function transcriptRoutes(): Hono {
return c.json({ error: resolution.error }, resolution.status as 404);
}
const raw = transcriptStore.readFull(resolution.rigName, sessionName);
if (raw === null) {
const ingest = await ensureTranscriptIngest(
db, transcriptStore, tmuxAdapter, resolution, sessionName,
);
const ingestHealth = ingest.health;
if (ingestHealth.state !== "live") {
return c.json(
{
error: `No transcript for '${sessionName}'. Transcripts start automatically on next rig up.`,
},
404,
{ error: transcriptIngestError(sessionName, ingestHealth, ingest.started), ingestHealth },
ingestHealth.state === "unavailable" ? 404 : 503,
);
}
const raw = transcriptStore.readFull(resolution.rigName, sessionName);
if (raw === null || raw === "") {
const emptyHealth: RouteIngestHealth = { ...ingestHealth, state: "degraded", reason: "capture_empty" };
return c.json({ error: transcriptIngestError(sessionName, emptyHealth), ingestHealth: emptyHealth }, 503);
}
// Apply route-level redaction BEFORE serialization. Per Quality Lesson
// v9 + orch decision approved-option-a: the wire payload MUST be
// already redacted; do NOT rely on client-side redaction.
const content = redactTranscriptContent(raw);
return c.json({ session: sessionName, content });
return c.json({ session: sessionName, content, ingestHealth });
});
return router;
+13
View File
@@ -59,6 +59,7 @@ import { UpCommandRouter } from "./domain/up-command-router.js";
import { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
import { TranscriptStore } from "./domain/transcript-store.js";
import { resumeRunningTranscriptCaptures } from "./domain/transcript-capture.js";
import { SessionTransport } from "./domain/session-transport.js";
import { AgentActivityStore } from "./domain/agent-activity-store.js";
import { HistoryQuery } from "./domain/history-query.js";
@@ -461,6 +462,18 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
console.log(`startup reconcile: rigs=${rigs.length} checked=${reconcileChecked} detached=${reconcileDetached} errors=${reconcileErrors}`);
} catch { /* logging must never throw */ }
// Transcript rotators are process-local. Reattach them after lifecycle
// reconciliation so surviving tmux sessions keep ingesting across daemon
// restarts while genuinely detached sessions stay excluded.
try {
await resumeRunningTranscriptCaptures(db, tmuxAdapter, transcriptStore);
} catch (err) {
try {
// eslint-disable-next-line no-console
console.warn(`startup transcript capture warning: ${err instanceof Error ? err.message : String(err)}`);
} catch { /* logging must never throw */ }
}
const podRepo = new PodRepository(db);
const rigSpecExporter = new RigSpecExporter({ rigRepo, sessionRegistry, podRepo });
const rigSpecPreflight = new RigSpecPreflight({
@@ -408,4 +408,30 @@ describe("ContextMonitor", () => {
expect(refreshed.startup_status).toBe("pending");
expect(checkReadySpy).not.toHaveBeenCalled();
});
it("coalesces overlapping pollOnce calls so one compaction stage is emitted once", async () => {
const { sessionName } = seedClaudeNode("dev.compact", "dev-compact@test");
writeSidecar(sessionName, { ...VALID_SIDECAR, session_name: sessionName });
let release!: () => void;
const blocked = new Promise<void>((resolve) => { release = resolve; });
const maybeAutoCompact = vi.fn(async () => {
await blocked;
return { triggered: true as const };
});
monitor = new ContextMonitor(
db,
store,
{ ensureContextCollector: ensureContextCollectorSpy, checkReady: checkReadySpy },
{ maybeAutoCompact } as never,
);
const timerTick = monitor.pollOnce();
await vi.waitFor(() => expect(maybeAutoCompact).toHaveBeenCalledTimes(1));
const refreshRequest = monitor.pollOnce();
release();
await Promise.all([timerTick, refreshRequest]);
expect(maybeAutoCompact).toHaveBeenCalledTimes(1);
});
});
@@ -840,6 +840,33 @@ describe("OPR.0.4.0.26 — node-list payload source dedupe", () => {
expect(ctx?.availability).toBe("known");
});
it("LIST exposes per-seat transcript ingest health from the capture store", () => {
seedPodAwareRig(db);
seedSession(db, "node-1", "dev-impl@test-rig");
const getIngestHealth = vi.fn(() => ({
state: "degraded",
reason: "capture_stale",
lastCapturedAt: "2026-08-02T08:00:00.000Z",
}));
const getWithTranscriptHealth = getNodeInventoryWithContext as unknown as (
db: Database.Database,
rigId: string,
contextStore: ContextUsageStore,
transcriptStore: { getIngestHealth: typeof getIngestHealth },
) => ReturnType<typeof getNodeInventoryWithContext>;
const entry = getWithTranscriptHealth(db, "rig-1", stubStore(), { getIngestHealth })
.find((candidate) => candidate.logicalId === "dev.impl");
expect(getIngestHealth).toHaveBeenCalledWith("test-rig", "dev-impl@test-rig");
expect(entry?.transcriptIngest).toEqual({
state: "degraded",
runtime: "claude-code",
reason: "capture_stale",
lastCapturedAt: "2026-08-02T08:00:00.000Z",
});
});
it("DETAIL contextUsage retains the full currentUsage (relocation, not loss)", () => {
seedPodAwareRig(db);
seedSession(db, "node-1", "dev-impl@test-rig");
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type Database from "better-sqlite3";
import { createDb } from "../src/db/connection.js";
import { migrate } from "../src/db/migrate.js";
import { coreSchema } from "../src/db/migrations/001_core_schema.js";
import { bindingsSessionsSchema } from "../src/db/migrations/002_bindings_sessions.js";
import { eventsSchema } from "../src/db/migrations/003_events.js";
import { snapshotsSchema } from "../src/db/migrations/004_snapshots.js";
import { checkpointsSchema } from "../src/db/migrations/005_checkpoints.js";
import { resumeMetadataSchema } from "../src/db/migrations/006_resume_metadata.js";
import { nodeSpecFieldsSchema } from "../src/db/migrations/007_node_spec_fields.js";
import { agentspecRebootSchema } from "../src/db/migrations/014_agentspec_reboot.js";
import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_cli_attachment.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import { clearAllTranscriptRotationsForTest } from "../src/domain/transcript-rotation.js";
describe("transcript capture boot recovery", () => {
let db: Database.Database;
let tmpDir: string;
beforeEach(() => {
db = createDb();
migrate(db, [
coreSchema,
bindingsSessionsSchema,
eventsSchema,
snapshotsSchema,
checkpointsSchema,
resumeMetadataSchema,
nodeSpecFieldsSchema,
agentspecRebootSchema,
externalCliAttachmentSchema,
]);
tmpDir = mkdtempSync(join(tmpdir(), "transcript-capture-boot-"));
});
afterEach(() => {
clearAllTranscriptRotationsForTest();
db.close();
rmSync(tmpDir, { recursive: true, force: true });
});
it("reattaches capture for running tmux sessions after a daemon restart", async () => {
const rigRepo = new RigRepository(db);
const sessions = new SessionRegistry(db);
const rig = rigRepo.createRig("restart-rig");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "claude-code" });
const session = sessions.registerSession(node.id, "dev-impl@restart-rig");
sessions.updateStatus(session.id, "running");
sessions.updateBinding(node.id, { tmuxSession: "dev-impl@restart-rig" });
const capturePaneContent = vi.fn(async () => "recent claude work\n");
const module = await import("../src/domain/transcript-capture.js") as Record<string, unknown>;
expect(typeof module["resumeRunningTranscriptCaptures"]).toBe("function");
const resume = module["resumeRunningTranscriptCaptures"] as (
db: Database.Database,
tmux: TmuxAdapter,
store: TranscriptStore,
) => Promise<number>;
const resumed = await resume(
db,
{ capturePaneContent } as unknown as TmuxAdapter,
new TranscriptStore({ transcriptsRoot: tmpDir }),
);
await new Promise((resolve) => setImmediate(resolve));
expect(resumed).toBe(1);
expect(capturePaneContent).toHaveBeenCalledWith("dev-impl@restart-rig", 1000);
});
});
+40 -5
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { mkdtempSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type Database from "better-sqlite3";
@@ -92,6 +92,45 @@ describe("transcript routes", () => {
expect(body.content).not.toContain("\x1b[");
});
it("GET /tail reports live transcript ingest metadata for a fresh capture", async () => {
const { store } = seedRigWithTranscript("recent work\n");
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
const res = await app.request("/api/transcripts/dev-impl@my-rig/tail?lines=10");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ingestHealth).toEqual(expect.objectContaining({
state: "live",
runtime: "claude-code",
}));
});
it("GET /tail fails loudly when an existing transcript is stale and capture cannot restart", async () => {
const { store } = seedRigWithTranscript("old misleading work\n");
const transcriptPath = store.getTranscriptPath("my-rig", "dev-impl@my-rig");
const old = new Date(Date.now() - 60_000);
utimesSync(transcriptPath, old, old);
const capturePaneContent = vi.fn(async () => null);
const app = createApp({
db,
rigRepo,
sessionRegistry,
transcriptStore: store,
tmuxAdapter: { capturePaneContent } as unknown as TmuxAdapter,
});
const res = await app.request("/api/transcripts/dev-impl@my-rig/tail?lines=10");
expect(res.status).toBe(503);
const body = await res.json();
expect(body.error).toContain("Transcript ingest degraded");
expect(body.error).toContain("claude-code");
expect(body.ingestHealth).toEqual(expect.objectContaining({
state: "degraded",
runtime: "claude-code",
reason: "capture_stale",
}));
});
it("GET /tail with unknown session returns 404 with guidance", async () => {
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
const app = createApp({ db, rigRepo, sessionRegistry, transcriptStore: store });
@@ -166,9 +205,6 @@ describe("transcript routes", () => {
sessionRegistry.updateBinding(node.id, { tmuxSession: "dev-impl@my-rig" });
const store = new TranscriptStore({ transcriptsRoot: tmpDir, enabled: true });
const readTailSpy = vi.spyOn(store, "readTail")
.mockReturnValueOnce(null)
.mockReturnValueOnce("READY\n");
vi.spyOn(store, "ensureTranscriptDir").mockReturnValue(true);
const capturePaneSpy = vi.fn(async () => "READY\n");
const app = createApp({
@@ -184,7 +220,6 @@ describe("transcript routes", () => {
const body = await res.json();
expect(body.content).toBe("READY\n");
expect(getActiveRotationCount()).toBeGreaterThan(0);
expect(readTailSpy).toHaveBeenCalledTimes(2);
clearAllTranscriptRotationsForTest();
});