@pasko70/pibo 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/agent-runtime/routed-session.js +80 -19
  2. package/dist/agent-runtimes/codex-native/turn.js +27 -3
  3. package/dist/apps/chat/data/chat-data-mappers.js +8 -1
  4. package/dist/apps/chat/data/read-state-service.js +24 -3
  5. package/dist/apps/chat/message-command-dispatcher.js +16 -1
  6. package/dist/apps/chat/web-app.js +40 -19
  7. package/dist/apps/chat-ui/assets/{dist-BG0n7zLd.js → dist-BQ-TQZet.js} +1 -1
  8. package/dist/apps/chat-ui/assets/{dist-D79vyxSX.js → dist-CYTRhePp.js} +1 -1
  9. package/dist/apps/chat-ui/assets/{dist-D6TjFhAm.js → dist-Crj0LNXs.js} +1 -1
  10. package/dist/apps/chat-ui/assets/{dist-DFZ8cwh0.js → dist-t24MVWWG.js} +1 -1
  11. package/dist/apps/chat-ui/assets/{dist-cOjokPrK.js → dist-tFj2S0WU.js} +1 -1
  12. package/dist/apps/chat-ui/assets/index-Bc9O0z52.css +1 -0
  13. package/dist/apps/chat-ui/assets/{index-RMHUTJ62.js → index-Cr2oFRhI.js} +77 -77
  14. package/dist/apps/chat-ui/index.html +2 -2
  15. package/dist/apps/chat-vscode-web/assets/{index-xacbCyTx.js → index-DgKYVP-6.js} +11 -11
  16. package/dist/apps/chat-vscode-web/index.html +1 -1
  17. package/dist/cli-session/localSessionSource.js +3 -2
  18. package/dist/core/output-render-sequence.js +63 -6
  19. package/dist/core/session-router.js +99 -11
  20. package/dist/data/async-chat-storage.js +7 -2
  21. package/dist/data/bounded-worker-client.js +1 -1
  22. package/dist/data/chat-read-projections.js +4 -4
  23. package/dist/data/chat-storage-worker.js +24 -3
  24. package/dist/data/ingest-service.js +73 -4
  25. package/dist/data/message-command-store.js +153 -11
  26. package/dist/data/schema.js +24 -3
  27. package/dist/data/storage-maintenance.js +344 -0
  28. package/dist/data/storage-verification-worker.js +25 -0
  29. package/dist/debug/cache.js +136 -0
  30. package/dist/debug/index.js +201 -1
  31. package/dist/debug/message-queue.js +108 -0
  32. package/dist/debug/output-collision-repair.js +140 -0
  33. package/dist/debug/output-integrity.js +38 -2
  34. package/dist/debug/output-repair.js +1 -0
  35. package/dist/debug/session.js +2 -0
  36. package/dist/debug/storage-backup.js +12 -4
  37. package/dist/debug/storage-maintenance.js +78 -0
  38. package/dist/debug/summary.js +1 -0
  39. package/dist/debug/trace.js +28 -0
  40. package/dist/gateway/cli.js +71 -7
  41. package/dist/gateway/server.js +1 -0
  42. package/dist/reliability/store.js +119 -23
  43. package/dist/session-ui/terminalRows.js +7 -8
  44. package/dist/sessions/pibo-data-store.js +18 -14
  45. package/dist/shared/cache-observability.js +73 -0
  46. package/dist/shared/model-inference-metrics.js +20 -7
  47. package/dist/shared/trace-event-projection.js +44 -6
  48. package/dist/web/channel.js +114 -12
  49. package/dist/web/http.js +105 -44
  50. package/npm-shrinkwrap.json +2 -2
  51. package/package.json +1 -1
  52. package/dist/apps/chat-ui/assets/index-hEkrlRk-.css +0 -1
@@ -43,6 +43,7 @@ export function repairOutputTurn(input) {
43
43
  actorId: "pibo-debug-repair",
44
44
  event: current.terminalEvent,
45
45
  createdAt,
46
+ persistenceProvenance: { producer: "debug-repair", projection: "product-history", phase: "operator-repair" },
46
47
  });
47
48
  const audit = data.eventLog.appendEvent({
48
49
  sessionId: input.piboSessionId,
@@ -48,6 +48,7 @@ export function inspectDebugSession(input, stores, options = {}) {
48
48
  `pibo debug messages ${parsed.piboSessionId} list`,
49
49
  `pibo debug final ${parsed.piboSessionId}`,
50
50
  `pibo debug trace ${parsed.piboSessionId} --check`,
51
+ `pibo debug cache ${parsed.piboSessionId}`,
51
52
  `pibo debug failures ${parsed.piboSessionId}`,
52
53
  `pibo debug events ${parsed.piboSessionId} --limit 20`,
53
54
  ];
@@ -114,6 +115,7 @@ export function inspectDebugSessionRuntime(input, stores) {
114
115
  nextCommands: [
115
116
  `pibo debug trace ${parsed.piboSessionId} --check`,
116
117
  `pibo debug trace ${parsed.piboSessionId} --native-history --check`,
118
+ `pibo debug cache ${parsed.piboSessionId}`,
117
119
  `pibo debug events ${parsed.piboSessionId} --limit 20`,
118
120
  ],
119
121
  };
@@ -1,5 +1,6 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { createStorageBackup, readStorageBackupManifest, verifyStorageBackup, restoreStorageBackup } from "../data/storage-backup.js";
3
+ import { recordStorageMaintenance } from "../data/storage-maintenance.js";
3
4
  export async function runStorageBackupCli(args) {
4
5
  const action = args[0];
5
6
  if (!action || args.includes("--help") || args.includes("-h")) {
@@ -19,12 +20,19 @@ No source deletion, vacuum, or overwrite of an existing restore destination.`);
19
20
  const required = (name) => { const value = values[name]; if (typeof value !== "string" || !value)
20
21
  throw Error(`Missing --${name}`); return value; };
21
22
  let result;
22
- if (action === "create")
23
- result = await createStorageBackup({ source: required("source"), payloadRoot: required("payload-root"), destination: required("destination"), resume: values.resume, maxBytes: values["max-bytes"] ? Number(values["max-bytes"]) : undefined, maxPayloads: values["max-payloads"] ? Number(values["max-payloads"]) : undefined, maxMilliseconds: values["max-ms"] ? Number(values["max-ms"]) : undefined, maxWalGrowthBytes: values["max-wal-growth"] ? Number(values["max-wal-growth"]) : undefined });
23
+ if (action === "create") {
24
+ const source = required("source");
25
+ result = await createStorageBackup({ source, payloadRoot: required("payload-root"), destination: required("destination"), resume: values.resume, maxBytes: values["max-bytes"] ? Number(values["max-bytes"]) : undefined, maxPayloads: values["max-payloads"] ? Number(values["max-payloads"]) : undefined, maxMilliseconds: values["max-ms"] ? Number(values["max-ms"]) : undefined, maxWalGrowthBytes: values["max-wal-growth"] ? Number(values["max-wal-growth"]) : undefined });
26
+ recordStorageMaintenance(source, { operation: "backup", at: new Date().toISOString(), status: result.status, destination: required("destination") });
27
+ }
24
28
  else if (action === "inspect")
25
29
  result = await readStorageBackupManifest(required("archive"));
26
- else if (action === "verify")
27
- result = await verifyStorageBackup(required("archive"), AbortSignal.timeout(60000));
30
+ else if (action === "verify") {
31
+ const archive = required("archive");
32
+ result = await verifyStorageBackup(archive, AbortSignal.timeout(60000));
33
+ const manifest = await readStorageBackupManifest(archive);
34
+ recordStorageMaintenance(manifest.source, { operation: "backup", at: new Date().toISOString(), status: "verified", archive });
35
+ }
28
36
  else if (action === "restore")
29
37
  result = await restoreStorageBackup(required("archive"), required("destination"), AbortSignal.timeout(60000));
30
38
  else
@@ -0,0 +1,78 @@
1
+ import { parseArgs } from "node:util";
2
+ import { piboHomePath } from "../core/pibo-home.js";
3
+ import { checkpointStorage, inspectStorageStatus, maintainStorageRetention, verifyStorage } from "../data/storage-maintenance.js";
4
+ import { resolveDebugStore } from "./stores.js";
5
+ export async function runStorageMaintenanceCli(args) {
6
+ const action = args[0];
7
+ if (!action || args.includes("--help") || args.includes("-h")) {
8
+ printHelp();
9
+ return;
10
+ }
11
+ const { values } = parseArgs({ args: args.slice(1), strict: true, options: {
12
+ store: { type: "string" }, path: { type: "string" }, json: { type: "boolean" },
13
+ "timeout-ms": { type: "string" }, full: { type: "boolean" }, quick: { type: "boolean" },
14
+ apply: { type: "boolean" }, "dry-run": { type: "boolean" }, mode: { type: "string" },
15
+ before: { type: "string" }, limit: { type: "string" }, "payload-root": { type: "string" },
16
+ "database-warn-bytes": { type: "string" }, "wal-warn-bytes": { type: "string" }, "payload-warn-bytes": { type: "string" },
17
+ } });
18
+ if (values.apply && values["dry-run"])
19
+ throw new Error("Choose either --dry-run or --apply");
20
+ const path = typeof values.path === "string" ? values.path : resolveStorePath(values.store);
21
+ let result;
22
+ if (action === "status" || action === "doctor") {
23
+ result = inspectStorageStatus({ path, databaseWarnBytes: numberValue(values["database-warn-bytes"]), walWarnBytes: numberValue(values["wal-warn-bytes"]), payloadWarnBytes: numberValue(values["payload-warn-bytes"]) });
24
+ }
25
+ else if (action === "verify") {
26
+ if (values.full && values.quick)
27
+ throw new Error("Choose either --quick or --full");
28
+ result = await verifyStorage({ path, mode: values.full ? "full" : "quick", timeoutMs: numberValue(values["timeout-ms"]) });
29
+ }
30
+ else if (action === "checkpoint") {
31
+ const mode = values.mode ?? "passive";
32
+ if (!new Set(["passive", "restart", "truncate"]).has(mode))
33
+ throw new Error("Checkpoint --mode must be passive, restart, or truncate");
34
+ result = checkpointStorage({ path, mode: mode, apply: values.apply });
35
+ }
36
+ else if (action === "retention") {
37
+ if (typeof values.before !== "string")
38
+ throw new Error("Storage retention requires --before <iso-date>");
39
+ result = await maintainStorageRetention({ path, before: values.before, limit: numberValue(values.limit), apply: values.apply, payloadRoot: typeof values["payload-root"] === "string" ? values["payload-root"] : piboHomePath("payloads") });
40
+ }
41
+ else
42
+ throw new Error(`Unknown storage action "${action}"; run pibo debug storage --help`);
43
+ if (values.json)
44
+ console.log(JSON.stringify(result, null, 2));
45
+ else
46
+ console.log(formatText(result));
47
+ }
48
+ function resolveStorePath(value) {
49
+ if (!value || value === "pibo-data")
50
+ return resolveDebugStore("pibo-data").path;
51
+ if (value === "reliability")
52
+ return resolveDebugStore("reliability").path;
53
+ throw new Error("--store must be pibo-data or reliability; use --path for another SQLite store");
54
+ }
55
+ function numberValue(value) { if (value === undefined)
56
+ return undefined; const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 0)
57
+ throw new Error(`Invalid numeric value "${value}"`); return parsed; }
58
+ function formatText(value) { const object = value; const lines = [`pibo debug storage ${String(object.resultType ?? "result").split(".").at(-1)}`]; for (const [key, item] of Object.entries(object)) {
59
+ if (key === "resultType" || typeof item === "object")
60
+ continue;
61
+ lines.push(`${key}\t${String(item)}`);
62
+ } if (Array.isArray(object.warnings))
63
+ lines.push(...object.warnings.map((warning) => `warning\t${String(warning)}`)); lines.push("", "Use --json for bounded row, size, projection, and progress detail."); return lines.join("\n"); }
64
+ function printHelp() {
65
+ console.log(`pibo debug storage - bounded SQLite health and maintenance
66
+
67
+ Commands:
68
+ status [--store pibo-data|reliability | --path <sqlite>] [--json]
69
+ doctor [--store pibo-data|reliability | --path <sqlite>] [--json]
70
+ verify [--quick|--full] [--timeout-ms <n>] [--store <name>|--path <sqlite>] [--json]
71
+ checkpoint [--mode passive|restart|truncate] [--dry-run|--apply] [--store <name>|--path <sqlite>] [--json]
72
+ retention --before <iso-date> [--limit <1..10000>] [--dry-run|--apply] [--payload-root <dir>] [--store <name>|--path <sqlite>] [--json]
73
+
74
+ Status is read-only and reports DB/WAL/SHM/payload size, pages/freelist, bounded row counts, payload-reference integrity, maintenance metadata, and degraded thresholds.
75
+ Verification runs in a cancellable worker. Timeout is partial and never healthy. Quick/full checks can still be I/O intensive; use backup verification or offline checks when the online budget is insufficient.
76
+ Checkpoint and retention default to dry-run. Apply is bounded and audited in the database maintenance sidecar. Retention deletes only eligible live_delta rows; chat messages, audit events, idempotency evidence, and referenced payloads are preserved.
77
+ `);
78
+ }
@@ -42,6 +42,7 @@ export async function inspectDebugSummary(input, stores) {
42
42
  `pibo debug failures ${parsed.piboSessionId}`,
43
43
  `pibo debug messages ${parsed.piboSessionId} list`,
44
44
  `pibo debug trace ${parsed.piboSessionId} --check`,
45
+ `pibo debug cache ${parsed.piboSessionId}`,
45
46
  `pibo debug events ${parsed.piboSessionId} --limit 20`,
46
47
  ],
47
48
  };
@@ -7,6 +7,8 @@ import { compareTraceNodes } from "../shared/trace-nodes.js";
7
7
  import { openReadOnlyDebugDatabase, withStorePath } from "./sql.js";
8
8
  import { formatNextCommands } from "./next-commands.js";
9
9
  import { resolveDebugTraceSessionStatus, summarizeDebugTraceStatus } from "./trace-status.js";
10
+ import { cacheUsageWarningText } from "../shared/cache-observability.js";
11
+ import { modelInferenceCacheReadRatio, modelInferenceCachedInputTokens, modelInferenceInputTokens, modelInferenceUncachedInputTokens, } from "../shared/model-inference-metrics.js";
10
12
  export async function inspectDebugTrace(piboSessionId, stores, options = {}) {
11
13
  if (!stores.sessions.exists)
12
14
  throw new Error(`Debug store "sessions" not found at ${stores.sessions.path}`);
@@ -113,6 +115,23 @@ export async function inspectDebugTraceNode(piboSessionId, stores, nodeId) {
113
115
  nextCommands: node ? buildNodeNextCommands(piboSessionId, node) : [`pibo debug trace ${piboSessionId}`],
114
116
  };
115
117
  }
118
+ function formatMetric(value) {
119
+ return value === undefined ? "unknown" : String(value);
120
+ }
121
+ function formatRatio(value) {
122
+ return value === undefined ? "unknown" : `${(value * 100).toFixed(1)}%`;
123
+ }
124
+ function formatModelInference(record) {
125
+ return [
126
+ record.id,
127
+ `input=${formatMetric(modelInferenceInputTokens(record.metrics))}`,
128
+ `cacheRead=${formatMetric(modelInferenceCachedInputTokens(record.metrics))}`,
129
+ `uncached=${formatMetric(modelInferenceUncachedInputTokens(record.metrics))}`,
130
+ `cacheWrite=${formatMetric(record.metrics.cacheWriteTokens)}`,
131
+ `output=${formatMetric(record.metrics.outputTokens)}`,
132
+ `cacheReadRatio=${formatRatio(modelInferenceCacheReadRatio(record.metrics))}`,
133
+ ].join("\t");
134
+ }
116
135
  export function formatDebugTrace(result, options = {}) {
117
136
  const lines = [
118
137
  `piboSessionId: ${result.piboSessionId}`,
@@ -152,6 +171,12 @@ export function formatDebugTrace(result, options = {}) {
152
171
  order: node.order,
153
172
  };
154
173
  lines.push(columns.map((column) => values[column] ?? "").join("\t"));
174
+ for (const inference of node.modelInferences ?? []) {
175
+ lines.push(`model-inference\t${formatModelInference(inference)}`);
176
+ const warning = inference.cacheObservation && cacheUsageWarningText(inference.cacheObservation);
177
+ if (warning)
178
+ lines.push(`cache-warning\t${inference.id}\t${warning}`);
179
+ }
155
180
  }
156
181
  lines.push(`nodes: ${result.nodes.length}${result.nodes.length !== result.rawNodeCount ? ` of ${result.rawNodeCount}` : ""}`);
157
182
  if (result.checks) {
@@ -194,6 +219,8 @@ export function formatDebugTraceNode(result) {
194
219
  lines.push(`runId: ${node.runId}`);
195
220
  if (node.toolCallId)
196
221
  lines.push(`toolCallId: ${node.toolCallId}`);
222
+ for (const inference of node.modelInferences ?? [])
223
+ lines.push(`modelInference: ${JSON.stringify(inference)}`);
197
224
  lines.push(...formatNextCommands(result.nextCommands));
198
225
  return lines.join("\n");
199
226
  }
@@ -214,6 +241,7 @@ function flattenTraceNodes(nodes, depth = 0) {
214
241
  startedAt: node.startedAt,
215
242
  completedAt: node.completedAt,
216
243
  childrenCount: node.children.length,
244
+ ...(node.modelInferences?.length ? { modelInferences: node.modelInferences } : {}),
217
245
  depth,
218
246
  },
219
247
  ...flattenTraceNodes(node.children, depth + 1),
@@ -231,14 +231,40 @@ function activeRun(value) {
231
231
  piboSessionId: stringValue(obj.controllerPiboSessionId) ?? stringValue(obj.piboSessionId),
232
232
  };
233
233
  }
234
+ function runJobReliability(value) {
235
+ const obj = objectValue(value);
236
+ if (!obj || (obj.status !== "ok" && obj.status !== "degraded")
237
+ || !Number.isInteger(obj.expiredOrphanRunJobs) || Number(obj.expiredOrphanRunJobs) < 0
238
+ || !Number.isInteger(obj.orphanRunDeadLetters) || Number(obj.orphanRunDeadLetters) < 0)
239
+ return undefined;
240
+ return {
241
+ status: obj.status,
242
+ expiredOrphanRunJobs: Number(obj.expiredOrphanRunJobs),
243
+ orphanRunDeadLetters: Number(obj.orphanRunDeadLetters),
244
+ };
245
+ }
246
+ function durableMessageQueueStatus(value) {
247
+ const obj = objectValue(value);
248
+ if (!obj)
249
+ return undefined;
250
+ const status = obj.status === "healthy" || obj.status === "degraded" || obj.status === "ambiguous" ? obj.status : undefined;
251
+ const storage = objectValue(obj.storage);
252
+ const counts = Array.isArray(obj.counts) ? obj.counts.flatMap(value => { const row = objectValue(value); return row ? [{ state: stringValue(row.state), delivery: stringValue(row.delivery), count: numberValue(row.count), bytes: numberValue(row.bytes) }] : []; }) : undefined;
253
+ const affectedScopes = Array.isArray(obj.affectedScopes) ? obj.affectedScopes.flatMap(value => { const row = objectValue(value); return row ? [{ sessionId: stringValue(row.sessionId), roomId: stringValue(row.roomId), blockingCommandId: stringValue(row.blockingCommandId), blockedSince: numberValue(row.blockedSince), blockedSuccessors: numberValue(row.blockedSuccessors) }] : []; }) : undefined;
254
+ return { status, storage: storage ? { available: booleanValue(storage.available), error: stringValue(storage.error) } : undefined, counts, interruptedPredecessors: numberValue(obj.interruptedPredecessors), blockedSuccessors: numberValue(obj.blockedSuccessors), expiredOwnedLeases: numberValue(obj.expiredOwnedLeases), oldestDispatchableWaitMs: numberValue(obj.oldestDispatchableWaitMs), oldestBlockedWaitMs: numberValue(obj.oldestBlockedWaitMs), affectedScopes, degradedReasons: Array.isArray(obj.degradedReasons) && obj.degradedReasons.every(v => typeof v === "string") ? obj.degradedReasons : undefined, admissionCapacity: obj.admissionCapacity, bounded: obj.bounded };
255
+ }
234
256
  function parseGatewaySafetyPayload(payload, reachable) {
235
257
  const obj = objectValue(payload);
236
258
  const mode = obj && (obj.mode === "dev" || obj.mode === "prod" || obj.mode === "fallback") ? obj.mode : "unknown";
237
259
  const runtimeStatuses = Array.isArray(obj?.runtimeStatuses) ? obj.runtimeStatuses.map(runtimeStatus).filter((item) => Boolean(item)) : [];
238
260
  const activeRuns = Array.isArray(obj?.activeRuns) ? obj.activeRuns.map(activeRun).filter((item) => Boolean(item)) : [];
261
+ const reliability = obj?.reliability === undefined ? undefined : runJobReliability(obj.reliability);
262
+ const durableMessageQueue = durableMessageQueueStatus(obj?.durableMessageQueue);
239
263
  const incomplete = !Array.isArray(obj?.runtimeStatuses) || !Array.isArray(obj?.activeRuns)
240
- || runtimeStatuses.length !== obj.runtimeStatuses.length || activeRuns.length !== obj.activeRuns.length;
241
- return { reachable, mode, generation: stringValue(obj?.generation), health: obj?.health, runtimeStatuses, activeRuns, ambiguous: incomplete || booleanValue(obj?.ambiguous) };
264
+ || runtimeStatuses.length !== obj.runtimeStatuses.length || activeRuns.length !== obj.activeRuns.length
265
+ || (obj?.reliability !== undefined && !reliability)
266
+ || (obj?.durableMessageQueue !== undefined && !durableMessageQueue?.status);
267
+ return { reachable, mode, generation: stringValue(obj?.generation), health: obj?.health, runtimeStatuses, activeRuns, reliability, durableMessageQueue, ambiguous: incomplete || booleanValue(obj?.ambiguous) };
242
268
  }
243
269
  export function checkActiveWork(status, target = "web") {
244
270
  const reasons = [];
@@ -337,7 +363,8 @@ function printSafetyStatus(target, status) {
337
363
  console.log(` mode: ${status.mode}`);
338
364
  if (status.error)
339
365
  console.log(` status error: ${status.error}`);
340
- console.log(` runtime sessions: ${status.runtimeStatuses.length}`);
366
+ console.log(" runtime queue layer:");
367
+ console.log(` sessions: ${status.runtimeStatuses.length}`);
341
368
  for (const session of status.runtimeStatuses) {
342
369
  console.log(` ${session.piboSessionId ?? "unknown"}: processing=${session.processing === true} streaming=${session.streaming === true} queued=${session.queuedMessages ?? 0}`);
343
370
  if (session.activeEventId)
@@ -359,6 +386,31 @@ function printSafetyStatus(target, status) {
359
386
  console.log(` active yielded runs: ${status.activeRuns.length}`);
360
387
  for (const run of status.activeRuns)
361
388
  console.log(` ${run.runId ?? "unknown"}: ${run.status ?? "active"}${run.toolName ? ` (${run.toolName})` : ""} session=${run.piboSessionId ?? "unknown"}`);
389
+ if (status.reliability) {
390
+ console.log(` run-job reliability: ${status.reliability.status}`);
391
+ console.log(` expired orphan jobs: ${status.reliability.expiredOrphanRunJobs}`);
392
+ console.log(` orphan DLQ records: ${status.reliability.orphanRunDeadLetters}`);
393
+ }
394
+ console.log(" durable message queue layer:");
395
+ const durable = status.durableMessageQueue;
396
+ if (!durable)
397
+ console.log(" status: unavailable (gateway app did not report this layer)");
398
+ else {
399
+ console.log(` status: ${durable.status ?? "ambiguous"}`);
400
+ console.log(` storage: ${durable.storage?.available === true ? "available" : "unavailable"}${durable.storage?.error ? ` (${durable.storage.error})` : ""}`);
401
+ for (const row of durable.counts ?? [])
402
+ console.log(` ${row.delivery ?? "unknown"}.${row.state ?? "unknown"}: count=${row.count ?? 0} bytes=${row.bytes ?? 0}`);
403
+ console.log(` interrupted predecessors: ${durable.interruptedPredecessors ?? 0}`);
404
+ console.log(` FIFO-blocked successors: ${durable.blockedSuccessors ?? 0}`);
405
+ console.log(` expired owned leases: ${durable.expiredOwnedLeases ?? 0}`);
406
+ console.log(` oldest dispatchable wait: ${durable.oldestDispatchableWaitMs ?? 0}ms`);
407
+ console.log(` oldest blocked wait: ${durable.oldestBlockedWaitMs ?? 0}ms`);
408
+ for (const scope of durable.affectedScopes ?? [])
409
+ console.log(` affected session=${scope.sessionId ?? "unknown"} room=${scope.roomId ?? "unknown"} blocker=${scope.blockingCommandId ?? "unknown"} successors=${scope.blockedSuccessors ?? 0}`);
410
+ for (const reason of durable.degradedReasons ?? [])
411
+ console.log(` degraded: ${reason}`);
412
+ }
413
+ console.log(" next: pibo debug message-queue");
362
414
  }
363
415
  function managerRequiresShell(command) {
364
416
  return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command);
@@ -385,8 +437,11 @@ async function waitForManagedGatewayHealth(target) {
385
437
  async function runManagedGatewayCommand(target, command, args, argv = process.argv) {
386
438
  if (command === "status" || command === "doctor") {
387
439
  const status = await readGatewaySafetyStatus(target);
388
- printSafetyStatus(target, status);
389
- if (target === "web") {
440
+ if (args.includes("--json"))
441
+ console.log(JSON.stringify({ ...status, nextCommands: [`pibo gateway ${target} doctor`, `pibo debug message-queue`] }, null, 2));
442
+ else
443
+ printSafetyStatus(target, status);
444
+ if (target === "web" && !args.includes("--json")) {
390
445
  const active = checkActiveWork(status, target);
391
446
  if (active.unsafe) {
392
447
  console.log(" restart safety: blocked");
@@ -397,8 +452,16 @@ async function runManagedGatewayCommand(target, command, args, argv = process.ar
397
452
  console.log(" restart safety: idle");
398
453
  printRestartApproval(status);
399
454
  }
400
- if (command === "doctor")
401
- process.exitCode = status.reachable && !status.error && status.mode === expectedMode(target) ? 0 : 1;
455
+ if (command === "doctor") {
456
+ process.exitCode = status.reachable
457
+ && !status.error
458
+ && status.mode === expectedMode(target)
459
+ && status.reliability?.status !== "degraded"
460
+ && status.durableMessageQueue?.status === "healthy"
461
+ && status.durableMessageQueue.storage?.available === true
462
+ ? 0
463
+ : 1;
464
+ }
402
465
  return true;
403
466
  }
404
467
  if (command === "start") {
@@ -730,6 +793,7 @@ Commands:
730
793
  dev doctor Check dev gateway health
731
794
 
732
795
  Options:
796
+ --json Print status or doctor output as JSON with next discovery commands
733
797
  --force --confirm <snapshot-token>
734
798
  Restart only the work explicitly approved from web status
735
799
 
@@ -427,6 +427,7 @@ export class PiboGatewayServer {
427
427
  listSessionRuntimeStatuses: () => this.requireRouter().listSessionRuntimeStatuses(),
428
428
  getRuntimeCapacityStatus: () => this.requireRouter().getRuntimeCapacityStatus(),
429
429
  listRuns: (options) => this.requireRouter().listRuns(options),
430
+ getRunJobReliabilityStatus: () => this.requireRouter().getRunJobReliabilityStatus(),
430
431
  snapshotSignalSession: (piboSessionId) => this.requireRouter().snapshotSignalSession(piboSessionId),
431
432
  snapshotSignalTree: (rootPiboSessionId) => this.requireRouter().snapshotSignalTree(rootPiboSessionId),
432
433
  snapshotSignalStatuses: () => this.requireRouter().snapshotSignalStatuses(),
@@ -44,12 +44,14 @@ function ensurePiboJobColumn(db, name, definition) {
44
44
  db.exec(`ALTER TABLE pibo_jobs ADD COLUMN ${name} ${definition}`);
45
45
  }
46
46
  export class PiboReliabilityStore {
47
+ options;
47
48
  db;
48
49
  appendEventStatement;
49
50
  getEventByStreamIdStatement;
50
51
  getEventByTopicEventIdStatement;
51
52
  getEventByTopicIdempotencyKeyStatement;
52
- constructor(path = piboHomePath("pibo-events.sqlite")) {
53
+ constructor(path = piboHomePath("pibo-events.sqlite"), options = {}) {
54
+ this.options = options;
53
55
  const resolvedPath = path === ":memory:" ? path : resolve(path);
54
56
  const insidePiboHome = ensurePrivatePiboHomeForPath(resolvedPath);
55
57
  if (resolvedPath !== ":memory:")
@@ -571,6 +573,38 @@ export class PiboReliabilityStore {
571
573
  }
572
574
  return jobs;
573
575
  }
576
+ inspectJobs(input = {}) {
577
+ const timestamp = now();
578
+ return this.listJobs(input).map((job) => this.inspectJob(job, timestamp));
579
+ }
580
+ reconcileOrphanRunJobs(input) {
581
+ const checkedAt = asDate(input.now).toISOString();
582
+ return this.inImmediateTransaction(() => this.reconcileOrphanRunJobsInTransaction(checkedAt, input.apply));
583
+ }
584
+ getRunJobReliabilityStatus() {
585
+ const timestamp = now();
586
+ const expired = this.db.prepare(`
587
+ SELECT COUNT(*) AS count
588
+ FROM pibo_jobs AS job
589
+ LEFT JOIN pibo_runs AS run ON run.job_id = job.job_id
590
+ WHERE job.queue = 'runs'
591
+ AND job.state = 'running'
592
+ AND job.claim_expires_at IS NOT NULL
593
+ AND job.claim_expires_at <= ?
594
+ AND run.run_id IS NULL
595
+ `).get(timestamp);
596
+ const dead = this.db.prepare(`
597
+ SELECT COUNT(*) AS count FROM pibo_dead_jobs
598
+ WHERE queue = 'runs' AND dead_reason = 'orphan_run_job'
599
+ `).get();
600
+ const expiredOrphanRunJobs = Number(expired.count);
601
+ const orphanRunDeadLetters = Number(dead.count);
602
+ return {
603
+ status: expiredOrphanRunJobs > 0 || orphanRunDeadLetters > 0 ? "degraded" : "ok",
604
+ expiredOrphanRunJobs,
605
+ orphanRunDeadLetters,
606
+ };
607
+ }
574
608
  listRecoverableJobs(input = {}) {
575
609
  const timestamp = now();
576
610
  const clauses = [
@@ -683,28 +717,37 @@ export class PiboReliabilityStore {
683
717
  const runId = input.runId ?? `run_${randomUUID()}`;
684
718
  const maxAttempts = Math.max(1, input.maxAttempts ?? 1);
685
719
  const timeoutAt = input.timeoutMs === undefined ? undefined : new Date(Date.parse(timestamp) + input.timeoutMs).toISOString();
686
- const job = this.enqueue({
687
- queue: "runs",
688
- payload: {
689
- runId,
690
- controllerPiboSessionId: input.controllerPiboSessionId,
691
- toolName: input.toolName,
692
- params: input.params,
693
- timeoutMs: input.timeoutMs,
694
- },
695
- maxAttempts,
720
+ return this.inImmediateTransaction(() => {
721
+ const job = this.enqueue({
722
+ queue: "runs",
723
+ runAt: timestamp,
724
+ payload: {
725
+ runId,
726
+ controllerPiboSessionId: input.controllerPiboSessionId,
727
+ toolName: input.toolName,
728
+ params: input.params,
729
+ timeoutMs: input.timeoutMs,
730
+ },
731
+ maxAttempts,
732
+ });
733
+ this.options.onRunCreationBoundary?.("after_enqueue");
734
+ const claimed = this.claimPendingJobInTransaction(job.jobId, input.workerId ?? `run-registry:${process.pid}`, timestamp, 24 * 60 * 60 * 1000);
735
+ if (!claimed)
736
+ throw new Error(`Failed to claim newly-created run job "${job.jobId}"`);
737
+ this.options.onRunCreationBoundary?.("after_claim");
738
+ this.options.onRunCreationBoundary?.("before_run_insert");
739
+ this.db
740
+ .prepare(`
741
+ INSERT INTO pibo_runs (
742
+ run_id, kind, controller_pibo_session_id, status, completion_policy, consumed, tool_name,
743
+ summary, result_json, error, notified_status, acknowledged_status, created_at, updated_at,
744
+ completed_at, job_id, retryable, max_attempts, timeout_ms, timeout_at, timeout_phase, service_warning, resource_json, origin_json
745
+ ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
746
+ `)
747
+ .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts, input.timeoutMs ?? null, timeoutAt ?? null, input.serviceWarning ?? null, input.resources ? JSON.stringify(input.resources) : null, input.origin ? JSON.stringify(input.origin) : null);
748
+ this.options.onRunCreationBoundary?.("after_run_insert");
749
+ return this.requireRun(runId);
696
750
  });
697
- this.db
698
- .prepare(`
699
- INSERT INTO pibo_runs (
700
- run_id, kind, controller_pibo_session_id, status, completion_policy, consumed, tool_name,
701
- summary, result_json, error, notified_status, acknowledged_status, created_at, updated_at,
702
- completed_at, job_id, retryable, max_attempts, timeout_ms, timeout_at, timeout_phase, service_warning, resource_json, origin_json
703
- ) VALUES (?, 'tool', ?, 'running', ?, 0, ?, ?, NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?, ?, ?, ?, NULL, ?, ?, ?)
704
- `)
705
- .run(runId, input.controllerPiboSessionId, input.completionPolicy, input.toolName, `${input.toolName} run is running.`, timestamp, timestamp, job.jobId, input.retryable ? 1 : 0, maxAttempts, input.timeoutMs ?? null, timeoutAt ?? null, input.serviceWarning ?? null, input.resources ? JSON.stringify(input.resources) : null, input.origin ? JSON.stringify(input.origin) : null);
706
- this.claimJob(job.jobId, input.workerId ?? `run-registry:${process.pid}`, 24 * 60 * 60 * 1000);
707
- return this.requireRun(runId);
708
751
  }
709
752
  updateRun(runId, patch) {
710
753
  const existing = this.getRun(runId);
@@ -779,9 +822,10 @@ export class PiboReliabilityStore {
779
822
  }
780
823
  recoverInterruptedRuns(workerId = `run-registry:${process.pid}`) {
781
824
  return this.inImmediateTransaction(() => {
825
+ const timestamp = now();
826
+ this.reconcileOrphanRunJobsInTransaction(timestamp, true);
782
827
  const rows = this.db.prepare("SELECT * FROM pibo_runs WHERE status = 'running'").all();
783
828
  const recovered = [];
784
- const timestamp = now();
785
829
  for (const row of rows) {
786
830
  if (row.job_id && this.hasUnexpiredJobClaim(row.job_id, timestamp, workerId))
787
831
  continue;
@@ -883,6 +927,58 @@ export class PiboReliabilityStore {
883
927
  `)
884
928
  .get(jobId, workerId, claimToken ?? null, claimToken ?? null, timestamp);
885
929
  }
930
+ claimPendingJobInTransaction(jobId, workerId, timestamp, visibilityTimeoutMs) {
931
+ const claimExpiresAt = new Date(Date.parse(timestamp) + visibilityTimeoutMs).toISOString();
932
+ return this.db.prepare(`
933
+ UPDATE pibo_jobs
934
+ SET state = 'running',
935
+ worker_id = ?,
936
+ claim_expires_at = ?,
937
+ claim_token = claim_token + 1,
938
+ attempts = attempts + 1,
939
+ updated_at = ?
940
+ WHERE job_id = ?
941
+ AND state = 'pending'
942
+ AND run_at <= ?
943
+ AND attempts < max_attempts
944
+ AND (expires_at IS NULL OR expires_at > ?)
945
+ RETURNING *
946
+ `).get(workerId, claimExpiresAt, timestamp, jobId, timestamp, timestamp);
947
+ }
948
+ inspectJob(job, timestamp) {
949
+ const claimExpired = job.state === "running"
950
+ && (!job.claimExpiresAt || job.claimExpiresAt <= timestamp);
951
+ const missingRunRecord = job.queue === "runs"
952
+ && this.db.prepare("SELECT 1 AS found FROM pibo_runs WHERE job_id = ? LIMIT 1").get(job.jobId) === undefined;
953
+ let effectiveLiveness;
954
+ if (job.state === "pending")
955
+ effectiveLiveness = missingRunRecord ? "orphan_pending" : "pending";
956
+ else if (claimExpired)
957
+ effectiveLiveness = missingRunRecord ? "expired_orphan" : "expired_claim";
958
+ else
959
+ effectiveLiveness = missingRunRecord ? "live_orphan_claim" : "live_claim";
960
+ return { ...job, claimExpired, missingRunRecord, effectiveLiveness };
961
+ }
962
+ reconcileOrphanRunJobsInTransaction(timestamp, apply) {
963
+ const rows = this.db.prepare(`
964
+ SELECT job.*
965
+ FROM pibo_jobs AS job
966
+ LEFT JOIN pibo_runs AS run ON run.job_id = job.job_id
967
+ WHERE job.queue = 'runs'
968
+ AND job.state = 'running'
969
+ AND job.claim_expires_at IS NOT NULL
970
+ AND job.claim_expires_at <= ?
971
+ AND run.run_id IS NULL
972
+ ORDER BY job.created_at ASC, job.job_id ASC
973
+ `).all(timestamp);
974
+ const candidates = rows.map((row) => this.inspectJob(jobFromRow(row), timestamp));
975
+ if (apply) {
976
+ for (const row of rows) {
977
+ this.moveJobToDead(row, "Expired runs job has no matching pibo_runs record.", "orphan_run_job", timestamp);
978
+ }
979
+ }
980
+ return { checkedAt: timestamp, apply, candidates, moved: apply ? rows.length : 0 };
981
+ }
886
982
  moveExpiredJobs(timestamp) {
887
983
  const expired = this.db.prepare("SELECT * FROM pibo_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?").all(timestamp);
888
984
  for (const row of expired)
@@ -23,8 +23,8 @@ export function buildCompactTerminalRows(traceView, options) {
23
23
  const candidates = syncThinkingToolRows(flatNodes.map((item) => createRowCandidate(item.node, item.turnId)));
24
24
  applyCompletedTurnTiming(candidates, turnById);
25
25
  const reconciled = reconcileConceptualRowCandidates(candidates);
26
- const rows = !showToolDebugMetrics && (options.toolDisplayMode ?? "default") === "default"
27
- ? groupRelatedToolCandidates(reconciled).map((candidate) => candidate.row)
26
+ const rows = (options.toolDisplayMode ?? "default") === "default"
27
+ ? groupRelatedToolCandidates(reconciled, showToolDebugMetrics).map((candidate) => candidate.row)
28
28
  : reconciled.map((candidate) => candidate.row);
29
29
  return applyToolDisplayMode(rows, options.toolDisplayMode ?? "default");
30
30
  }
@@ -942,12 +942,12 @@ function isThinkingOutput(value) {
942
942
  function isThinkingLevelSetOutput(value) {
943
943
  return isRecord(value) && value.action === "set_thinking_level";
944
944
  }
945
- function groupRelatedToolCandidates(candidates) {
945
+ function groupRelatedToolCandidates(candidates, imagesOnly = false) {
946
946
  const grouped = [];
947
947
  for (let index = 0; index < candidates.length; index += 1) {
948
948
  const candidate = candidates[index];
949
949
  const groupKind = candidateGroupKind(candidate);
950
- if (!groupKind) {
950
+ if (!groupKind || (imagesOnly && groupKind !== "images")) {
951
951
  grouped.push(candidate);
952
952
  continue;
953
953
  }
@@ -1028,21 +1028,20 @@ function createImageGroup(candidates) {
1028
1028
  const omittedDetailCount = Math.max(0, detailItems.length - visibleDetailItems.length);
1029
1029
  const firstRow = candidates[0]?.row;
1030
1030
  const firstId = firstRow?.id ?? "images";
1031
- const lastId = candidates[candidates.length - 1]?.row.id ?? firstId;
1032
1031
  const status = candidates.some((candidate) => candidate.row.status === "running")
1033
1032
  ? "running"
1034
1033
  : candidates.some((candidate) => candidate.row.status === "error")
1035
1034
  ? "error"
1036
1035
  : "done";
1037
1036
  return {
1038
- id: `group:images:${firstId}:${lastId}`,
1037
+ id: firstId,
1039
1038
  kind: "tool.group.images",
1040
1039
  status,
1041
1040
  errorKind: status === "error" ? "tool" : undefined,
1042
1041
  lines: [
1043
1042
  {
1044
1043
  prefix: "bullet",
1045
- tokens: [token(status === "running" ? "Viewing images" : status === "error" ? "Image reads failed" : "Viewed images", toneForStatus(status), "semibold")],
1044
+ tokens: [token(status === "running" ? `Viewing ${detailItems.length} images` : status === "error" ? `${detailItems.length} image reads · error` : `${detailItems.length} ${detailItems.length === 1 ? "Image Viewed" : "Images Viewed"}`, toneForStatus(status), "semibold")],
1046
1045
  },
1047
1046
  ...visibleDetailItems.map((item, index) => ({
1048
1047
  prefix: index === 0 ? "detail" : "continuation",
@@ -1062,7 +1061,7 @@ function createImageGroup(candidates) {
1062
1061
  orderStreamFrameIndex: firstRow?.orderStreamFrameIndex,
1063
1062
  detailItems,
1064
1063
  expandable: detailItems.some((item) => item.toolCallReference || item.input !== undefined || item.output !== undefined || Boolean(item.error)),
1065
- imagePreviews: detailItems.flatMap((item) => item.imagePreviews ?? []).slice(0, MAX_COMPACT_TERMINAL_IMAGE_PREVIEWS),
1064
+ imagePreviews: detailItems.flatMap((item) => item.imagePreviews ?? []),
1066
1065
  previewOmission: omittedDetailCount > 0 ? {
1067
1066
  source: "details",
1068
1067
  visibleLineCount: visibleDetailItems.length,
@@ -227,21 +227,21 @@ export class PiboDataSessionStore {
227
227
  if (typeof index !== "number" || !Number.isSafeInteger(index) || index < 0)
228
228
  continue;
229
229
  maximum = Math.max(maximum, index);
230
- persistedParts.push({ index, attributes });
230
+ persistedParts.push({ index, type: row.type, attributes });
231
231
  }
232
- // An unfinished durable part may still belong to another process. Only a
233
- // completed turn provides enough evidence to reattach an exact replay.
234
- if (turnCompleted) {
235
- const matchesReplay = ({ attributes }) => attributes.outputPartFingerprint === input.fingerprint
236
- || attributes.identityFingerprint === input.identityFingerprint;
237
- const replay = (input.suppliedIndex === undefined
238
- ? undefined
239
- : persistedParts.find((part) => part.index === input.suppliedIndex && matchesReplay(part)))
240
- ?? persistedParts.find(matchesReplay);
241
- if (replay) {
242
- this.observeOutputPartIndex(input, replay.index);
243
- return replay.index;
244
- }
232
+ // A completed turn or an exact terminal-part fingerprint is durable
233
+ // evidence for replay. Nonterminal parts remain unattached while the turn
234
+ // is open because another producer may still own them.
235
+ const matchesReplay = ({ type, attributes }) => (turnCompleted || outputPartEventIsTerminal(type))
236
+ && (attributes.outputPartFingerprint === input.fingerprint
237
+ || attributes.identityFingerprint === input.identityFingerprint);
238
+ const replay = (input.suppliedIndex === undefined
239
+ ? undefined
240
+ : persistedParts.find((part) => part.index === input.suppliedIndex && matchesReplay(part)))
241
+ ?? (turnCompleted ? persistedParts.find(matchesReplay) : undefined);
242
+ if (replay) {
243
+ this.observeOutputPartIndex(input, replay.index);
244
+ return replay.index;
245
245
  }
246
246
  const minimum = Math.max(input.proposedIndex, maximum + 1);
247
247
  const row = this.db.prepare(`
@@ -538,6 +538,7 @@ export class PiboDataSessionStore {
538
538
  actorId: session.id,
539
539
  event,
540
540
  createdAt: at,
541
+ persistenceProvenance: { producer: "runtime-recovery", projection: "product-history", phase: "startup-recovery" },
541
542
  });
542
543
  results.push({
543
544
  turnId: recovered.turn.turnId,
@@ -726,6 +727,9 @@ function outputPartEventTypes(kind) {
726
727
  case "compaction": return ["compaction_start", "compaction_end"];
727
728
  }
728
729
  }
730
+ function outputPartEventIsTerminal(type) {
731
+ return type === "assistant_message" || type === "thinking_finished" || type === "assistant_usage" || type === "compaction_end";
732
+ }
729
733
  function parseJsonObject(json) {
730
734
  if (!json)
731
735
  return {};