mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
Fix compaction recovery and transcript ingest honesty
This commit is contained in:
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.
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
+4
-2
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user