@sigrun/sigrun 0.10.2 → 0.11.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 (42) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +19 -18
  3. package/dist/curation/runs.js +65 -50
  4. package/dist/curation/skill-tool.js +1 -1
  5. package/dist/extension.d.ts +2 -1
  6. package/dist/harness/blocks/tools.js +1 -1
  7. package/dist/harness/boot.js +94 -28
  8. package/dist/harness/decision-tool.js +1 -1
  9. package/dist/harness/decisions.js +1 -1
  10. package/dist/harness/event-bus.js +19 -9
  11. package/dist/harness/loader.js +14 -6
  12. package/dist/harness/migration-snapshot.js +104 -0
  13. package/dist/harness/packages.js +2 -1
  14. package/dist/harness/reports.js +83 -35
  15. package/dist/harness/tools.js +1 -1
  16. package/dist/jobs/docs.js +23 -3
  17. package/dist/jobs/index.js +74 -15
  18. package/dist/jobs/observe.js +26 -9
  19. package/dist/jobs/reconcile.js +12 -3
  20. package/dist/jobs/restart-limit.js +39 -0
  21. package/dist/sandbox/protocol.js +3 -2
  22. package/dist/sandbox/sandboxed-env.js +4 -0
  23. package/dist/sandbox/worker.js +21 -4
  24. package/dist/server/conversation-watch.js +49 -0
  25. package/dist/server/helpers.js +5 -5
  26. package/dist/server/host-bridge.js +6 -3
  27. package/dist/server/routes/admin.js +7 -36
  28. package/dist/server/routes/jobs.js +30 -1
  29. package/dist/server/routes/metrics.js +31 -7
  30. package/dist/server/routes/stream.js +6 -3
  31. package/dist/server/schema-fixture.js +59 -56
  32. package/dist/skills/index.js +1 -1
  33. package/node_modules/@sigrun/shared/CHANGELOG.md +4 -0
  34. package/node_modules/@sigrun/shared/dist/live.d.ts +25 -0
  35. package/node_modules/@sigrun/shared/dist/live.js +30 -0
  36. package/node_modules/@sigrun/shared/dist/views.d.ts +15 -0
  37. package/node_modules/@sigrun/shared/package.json +1 -1
  38. package/package.json +14 -14
  39. package/webui/dist/assets/{index-bUGUhbPh.js → index-B77MiI5E.js} +1 -1
  40. package/webui/dist/assets/{index-DALBZuYo.css → index-BvOKatk4.css} +1 -1
  41. package/webui/dist/index.html +2 -2
  42. package/dist/jobs/parked.js +0 -38
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The full-history conversation watch (pi-durable 1.5.0 Task 2, D1-A).
3
+ *
4
+ * D1-A: a conversation view's `entries` is the append-only, fork-aware RAW
5
+ * HISTORY keyed by entry id — never the model's context — and nothing in
6
+ * core cuts entries at a `pi.compaction`/`pi.reset` head marker. Upstream
7
+ * windows DEFAULT to sliding (`{min: 50, max: 100}`), which would drop the
8
+ * oldest entries once a conversation outgrows 100; pinning the window to
9
+ * {@link FULL_HISTORY_WINDOW} makes the watch never slide.
10
+ *
11
+ * One window for every server-side watch: the SSE view stream
12
+ * (`routes/stream.ts`), the host bridge's `conversations.watch`
13
+ * (`host-bridge.ts`), and one-shot reads (`helpers.ts` `readView`).
14
+ */
15
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
16
+ /**
17
+ * The never-sliding window: `min`/`max` at the upstream cap (`2 ** 31`
18
+ * validation ceiling), so the watch holds every entry the transcript ever
19
+ * gains and never drops its oldest.
20
+ */
21
+ export const FULL_HISTORY_WINDOW = {
22
+ min: 2 ** 30,
23
+ max: 2 ** 31,
24
+ };
25
+ /**
26
+ * Watch a conversation's full history. The returned handle's `value` is the
27
+ * acquisition revision (every entry so far); later revisions keep the whole
28
+ * history because the window never slides.
29
+ */
30
+ export function watchFullHistory(conversation, context) {
31
+ return conversation.watch(context, FULL_HISTORY_WINDOW);
32
+ }
33
+ /**
34
+ * Read one conversation's current full-history view; disposes the watch
35
+ * handle (the temporary Task-1 `readView` shape, now never-sliding).
36
+ */
37
+ export async function readFullView(conversation, context) {
38
+ const watch = await watchFullHistory(conversation, context);
39
+ try {
40
+ return watch.value;
41
+ }
42
+ finally {
43
+ await watch.stop();
44
+ }
45
+ }
46
+ /** Convenience: the background-context read (helpers.ts `readView`). */
47
+ export function readFullConversationView(conversation) {
48
+ return readFullView(conversation, BACKGROUND_CONTEXT);
49
+ }
@@ -4,6 +4,7 @@
4
4
  * the route modules consume.
5
5
  */
6
6
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
7
+ import { readFullConversationView } from "./conversation-watch.js";
7
8
  import { existsSync, readFileSync } from "node:fs";
8
9
  import { homedir } from "node:os";
9
10
  import { isAbsolute, resolve } from "node:path";
@@ -232,12 +233,11 @@ export function conversationId(id) {
232
233
  export function submissionId(id) {
233
234
  return id;
234
235
  }
235
- /** Read one conversation's current view frame; disposes the state handle. */
236
+ /** Read one conversation's current view frame; disposes the watch handle. */
236
237
  export async function readView(conversation) {
237
- const state = await conversation.viewState(BACKGROUND_CONTEXT);
238
- const view = serializeConversationView(state.value);
239
- await state.dispose();
240
- return view;
238
+ // Task 2 (D1-A): the full-history read — the window never slides, so
239
+ // `entries` is the raw append-only history, never cut at a head marker.
240
+ return serializeConversationView(await readFullConversationView(conversation));
241
241
  }
242
242
  /**
243
243
  * Stamp the resolved caller identity onto a fresh conversation's
@@ -16,6 +16,7 @@
16
16
  * superuser's HTTP list does: every row visible, `access: "write"`.
17
17
  */
18
18
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { watchFullHistory } from "./conversation-watch.js";
19
20
  import { accessOf as accessOfShared } from "@sigrun/shared/access";
20
21
  import { completeOnce } from "../harness/complete.js";
21
22
  import { effectiveAdmin } from "../harness/identity/groups.js";
@@ -83,14 +84,16 @@ export function createHostBridge(ctx) {
83
84
  // acquisition revision is the first listener call, then every
84
85
  // watch revision after (pending frames flow because start
85
86
  // never invokes the listener inline).
86
- const watch = await conversation.watch(BACKGROUND_CONTEXT);
87
+ const watch = await watchFullHistory(conversation, BACKGROUND_CONTEXT);
87
88
  listener(serializeConversationView(watch.value));
88
- watch.start((value) => {
89
+ watch.start((call) => {
90
+ if (call.kind !== "value")
91
+ return Promise.resolve();
89
92
  // An extension listener that throws (or returns a rejecting
90
93
  // promise — async listeners) must not end the watch or the
91
94
  // process — warn and keep delivering.
92
95
  return Promise.resolve()
93
- .then(() => listener(serializeConversationView(value)))
96
+ .then(() => listener(serializeConversationView(call.value)))
94
97
  .catch((cause) => {
95
98
  console.warn(`host bridge: watch listener failed: ${cause instanceof Error ? cause.message : String(cause)}`);
96
99
  });
@@ -18,15 +18,13 @@
18
18
  * graceful-shutdown + `process.exit(0)`) owns the exit — the route
19
19
  * never calls exit itself.
20
20
  *
21
- * D5 parked-task recovery (pi-durable 1.4.0, Task B6):
21
+ * D5 blocked-task visibility (pi-durable 1.5.0):
22
22
  * `GET /v1/admin/tasks?state=blocked` lists blocked tasks from
23
23
  * `harness.inspect()` (id, kind, reason, conversationId, owner from the
24
- * conversation's SessionMeta when present); `POST /v1/admin/tasks/:id/retry`
25
- * returns 404 unknown_task / 409 not_parked unless the task is blocked
26
- * with reason restart_limit, else calls `harness.retryTask(id)` → 204.
27
- * The inspect-first check is load-bearing: retryTask rejects an unknown
28
- * id but SILENTLY no-ops for a task that isn't parked. Both endpoints
29
- * are admin-only (403 forbidden).
24
+ * conversation's SessionMeta when present). Admin-only (403 forbidden).
25
+ * The LISTING stays; the retry route is gone — 1.5.0 removed
26
+ * task_parked/retryTask, and its replacement recovery surface is the
27
+ * job-level `POST /v1/jobs/:id/resume` (Task 4, D2).
30
28
  *
31
29
  * Durability (Review Focus 4): the restart rides the SAME ordered
32
30
  * shutdown as the SIGTERM path — the event bus drains pending settles
@@ -67,9 +65,8 @@ export function registerAdminRoutes(app, ctx) {
67
65
  });
68
66
  return await reply.code(202).send({ restarting: true });
69
67
  });
70
- // D5 parked-task recovery. `GET /v1/admin/tasks?state=blocked` lists
71
- // blocked tasks from `harness.inspect()`; `POST /v1/admin/tasks/:id/retry`
72
- // re-runs a restart-parked task. Both admin-only (403 before any
68
+ // D5 blocked-task visibility. `GET /v1/admin/tasks?state=blocked` lists
69
+ // blocked tasks from `harness.inspect()` — admin-only (403 before any
73
70
  // inspection, so a non-admin learns nothing about the task graph).
74
71
  app.get("/v1/admin/tasks", async (request, reply) => {
75
72
  if (!request.principal.admin) {
@@ -126,30 +123,4 @@ export function registerAdminRoutes(app, ctx) {
126
123
  });
127
124
  return await reply.send({ tasks });
128
125
  });
129
- app.post("/v1/admin/tasks/:id/retry", async (request, reply) => {
130
- if (!request.principal.admin) {
131
- return await reply.code(403).send({ error: "forbidden" });
132
- }
133
- const { id } = request.params;
134
- const taskId = Number(id);
135
- if (!Number.isInteger(taskId) || taskId < 0 || String(taskId) !== id) {
136
- return await reply.code(404).send({ error: "unknown_task" });
137
- }
138
- // Check inspect() FIRST: pi-durable's retryTask rejects an unknown
139
- // id but SILENTLY no-ops for a task that isn't parked — the 409
140
- // must come from the inspection, not from retryTask.
141
- const inspection = await ctx.opened.harness.inspect(BACKGROUND_CONTEXT);
142
- const task = inspection.tasks.find((candidate) => String(candidate.record.id) === id);
143
- if (task === undefined) {
144
- return await reply.code(404).send({ error: "unknown_task" });
145
- }
146
- if (task.state.kind !== "blocked" || task.state.reason !== "restart_limit") {
147
- return await reply.code(409).send({ error: "not_parked" });
148
- }
149
- // For a schedule task the job's status fields return to `waiting`
150
- // on the task's next committed state (the task's own phases patch
151
- // the record) — the route deliberately does NOT hand-patch the job.
152
- await ctx.opened.harness.retryTask(task.record.id, BACKGROUND_CONTEXT);
153
- return await reply.code(204).send();
154
- });
155
126
  }
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { accessPrincipalOf } from "../auth/access-principal.js";
26
26
  import { jobAccess, resolveJob, } from "../jobs-access.js";
27
- import { JobDeclaredError, JobNotFoundError } from "../../jobs/index.js";
27
+ import { JobDeclaredError, JobNotBlockedError, JobNotFoundError } from "../../jobs/index.js";
28
28
  import { validateJobSpec } from "../../jobs/spec.js";
29
29
  /**
30
30
  * The shared `:id` error mapping: unknown/hidden → 404 `job_not_found`;
@@ -45,6 +45,9 @@ async function domainError(reply, error) {
45
45
  if (error instanceof JobNotFoundError) {
46
46
  return await reply.code(404).send({ error: "job_not_found" });
47
47
  }
48
+ if (error instanceof JobNotBlockedError) {
49
+ return await reply.code(409).send({ error: "not_blocked" });
50
+ }
48
51
  throw error;
49
52
  }
50
53
  /** Register the `/v1/jobs` routes. */
@@ -229,6 +232,32 @@ export function registerJobRoutes(app, ctx) {
229
232
  }
230
233
  return await jobs.runNow(id);
231
234
  });
235
+ // POST /v1/jobs/:id/resume — restart-limit recovery (D2). EXACTLY
236
+ // the run route's access checks: unknown or hidden → 404
237
+ // job_not_found; read-only → 403 forbidden. A job that is not
238
+ // restart-limited → 409 not_blocked; a blocked/restart_limit job
239
+ // re-arms (state waiting, executor recreated) → 200 with the wire.
240
+ app.post("/v1/jobs/:id/resume", async (request, reply) => {
241
+ const { id } = request.params;
242
+ const wire = await jobs.get(id);
243
+ if (wire === undefined) {
244
+ return await deny(reply, "job_not_found");
245
+ }
246
+ const access = jobAccess(accessPrincipal(request), wire);
247
+ if (access === "none") {
248
+ return await deny(reply, "job_not_found");
249
+ }
250
+ if (access === "read") {
251
+ return await deny(reply, "forbidden");
252
+ }
253
+ try {
254
+ const resumed = await jobs.resume(id);
255
+ return { job: resumed };
256
+ }
257
+ catch (error) {
258
+ return await domainError(reply, error);
259
+ }
260
+ });
232
261
  // GET /v1/jobs/:id/runs?limit= — newest first; answers for retired
233
262
  // jobs (the revision-resolved access) until retention trims them.
234
263
  app.get("/v1/jobs/:id/runs", async (request, reply) => {
@@ -55,14 +55,14 @@
55
55
  * type is `text/plain; version=0.0.4; charset=utf-8`.
56
56
  */
57
57
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
58
- import { REPORT_TYPES } from "../../harness/reports.js";
58
+ import { REPORT_KINDS } from "../../harness/reports.js";
59
59
  import { readBackupMarker } from "../../backup/marker.js";
60
60
  import { scanJobs } from "../../jobs/docs.js";
61
61
  import { providerAuthStatus } from "../helpers.js";
62
62
  import { checkStorage } from "../readiness.js";
63
63
  /** The exposition content type (spec §6.9's format rule). */
64
64
  const EXPOSITION_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
65
- const BLOCKED_REASONS = ["missing_task", "task_too_old", "migration_failed", "restart_limit"];
65
+ const BLOCKED_REASONS = ["missing_task", "task_too_old", "migration_failed"];
66
66
  /** Escape one label value per the exposition format: `\` → `\\`,
67
67
  * `"` → `\"`, newline → `\n`. */
68
68
  function escapeLabelValue(value) {
@@ -124,11 +124,12 @@ export function registerMetricsRoutes(app, ctx) {
124
124
  const oldestMs = opened.eventBus.oldestLiveAdmissionMs();
125
125
  family("sigrun_inflight_submission_oldest_seconds", "Age of the oldest in-flight submission's admission (0 when none; in-process clock — a pre-restart run is invisible until it settles)", [{ labels: {}, value: Math.round((oldestMs ?? 0) / 1000) }]);
126
126
  // Live tasks by scheduler state kind. The full six-kind
127
- // vocabulary is emitted (0s included) for label stability
128
- // (1.4.0 adds `sleeping` — the durable-sleep state).
127
+ // vocabulary is emitted (0s included) for label stability.
128
+ // pi-durable 1.5.0 renamed `ready` to `pending` (the queued,
129
+ // runnable-but-not-yet-admitted state).
129
130
  const taskStatuses = {
130
131
  running: 0,
131
- ready: 0,
132
+ pending: 0,
132
133
  waiting: 0,
133
134
  sleeping: 0,
134
135
  completing: 0,
@@ -171,6 +172,26 @@ export function registerMetricsRoutes(app, ctx) {
171
172
  value: epochSeconds(record.nextRun),
172
173
  },
173
174
  ]));
175
+ // D2: the job-record blocked gauge. pi-durable 1.5.0 has no
176
+ // task park state — a restart-limited schedule task ends
177
+ // faulted and its JOB record carries blocked/restart_limit —
178
+ // so the alertable signal lives on the job records. Emitted
179
+ // for restart_limit plus zero-valued label stability; other
180
+ // blocked reasons (fire-time blocks) ride the same gauge when
181
+ // they occur.
182
+ const jobBlockedReasons = {
183
+ restart_limit: 0,
184
+ };
185
+ for (const { record } of jobRecords) {
186
+ if (record.state !== "blocked")
187
+ continue;
188
+ const reason = record.reason ?? "unknown";
189
+ jobBlockedReasons[reason] = (jobBlockedReasons[reason] ?? 0) + 1;
190
+ }
191
+ family("sigrun_jobs_blocked", "Jobs blocked by record reason", Object.entries(jobBlockedReasons).map(([reason, count]) => ({
192
+ labels: { reason },
193
+ value: count,
194
+ })));
174
195
  }
175
196
  catch (error) {
176
197
  up = 0;
@@ -185,10 +206,13 @@ export function registerMetricsRoutes(app, ctx) {
185
206
  // failure even while inspection (and the whole scrape) stays green.
186
207
  const storage = await checkStorage(ctx);
187
208
  family("sigrun_ready", "1 when the readiness storage check (the /readyz root-conversation read) succeeds", [{ labels: {}, value: storage === "ok" ? 1 : 0 }]);
188
- // D5: process-local durable failure reports. Every known type is
209
+ // D5: process-local durable failure reports. Every known kind is
189
210
  // emitted, including zeros, for scrape-stable alert expressions.
211
+ // pi-durable 1.5.0 kinds: `task_parked`/`session_failed` are gone;
212
+ // `storage_failed`/`task_interrupted` replaced them (label name
213
+ // `type` kept — a metrics-label rename would break alert queries).
190
214
  const reportCounts = opened.reports.counts();
191
- family("sigrun_durable_reports_total", "Durable failure reports since process start", REPORT_TYPES.map((type) => ({ labels: { type }, value: reportCounts[type] })), "counter");
215
+ family("sigrun_durable_reports_total", "Durable failure reports since process start", REPORT_KINDS.map((kind) => ({ labels: { type: kind }, value: reportCounts[kind] })), "counter");
192
216
  // Provider readiness: the shared derivation (`GET /v1/auth/check`'s
193
217
  // exact chain), one 1-valued sample per (provider, current status).
194
218
  const producersByName = new Map(opened.providerHealth.map((producer) => [producer.name, producer]));
@@ -11,6 +11,7 @@ import { loadConversation } from "../conversation-handler.js";
11
11
  import { ID_PARAMS_SCHEMA } from "../helpers.js";
12
12
  import { serializeConversationView, } from "../views.js";
13
13
  import { diffViews, isEmptyDelta, } from "@sigrun/shared/view-delta";
14
+ import { watchFullHistory } from "../conversation-watch.js";
14
15
  /** Register the SSE view stream route. */
15
16
  export function registerStreamRoutes(app, ctx) {
16
17
  // SSE view stream (spec §5 "Views + watch frames over SSE", §7.3 ruling
@@ -35,7 +36,7 @@ export function registerStreamRoutes(app, ctx) {
35
36
  // error event. `value` is the acquisition revision (the snapshot
36
37
  // source); frames published between here and start() are held by the
37
38
  // watch's bounded pending delivery, so nothing is lost.
38
- const watch = await conversation.watch(BACKGROUND_CONTEXT);
39
+ const watch = await watchFullHistory(conversation, BACKGROUND_CONTEXT);
39
40
  // Watcher bookkeeping (W5 Task 6, spec §9.6): one attached client.
40
41
  // The exactly-once `finish` below decrements — the notifications
41
42
  // surface's `"unwatched"` runSettled rule reads this count.
@@ -115,10 +116,12 @@ export function registerStreamRoutes(app, ctx) {
115
116
  // view the diff loop measures against.
116
117
  let prev = serializeConversationView(watch.value);
117
118
  send("snapshot", prev);
118
- watch.start(async (value) => {
119
+ watch.start(async (call) => {
119
120
  if (finished)
120
121
  return; // the stream is over; drop the frame
121
- const next = serializeConversationView(value);
122
+ if (call.kind !== "value")
123
+ return;
124
+ const next = serializeConversationView(call.value);
122
125
  const delta = diffViews(prev, next);
123
126
  prev = next;
124
127
  // Not representable as entries append/remove + changed docs (a
@@ -223,6 +223,9 @@ export const VIEW_SCHEMA = {
223
223
  },
224
224
  "timestamp": {
225
225
  "kind": "number"
226
+ },
227
+ "thinkingLevel": {
228
+ "kind": "string"
226
229
  }
227
230
  }
228
231
  }
@@ -292,14 +295,6 @@ export const VIEW_SCHEMA = {
292
295
  "type": {
293
296
  "kind": "string"
294
297
  },
295
- "required": {
296
- "kind": "array",
297
- "variants": [
298
- {
299
- "kind": "string"
300
- }
301
- ]
302
- },
303
298
  "properties": {
304
299
  "kind": "object",
305
300
  "fields": {
@@ -315,6 +310,14 @@ export const VIEW_SCHEMA = {
315
310
  }
316
311
  }
317
312
  }
313
+ },
314
+ "required": {
315
+ "kind": "array",
316
+ "variants": [
317
+ {
318
+ "kind": "string"
319
+ }
320
+ ]
318
321
  }
319
322
  }
320
323
  },
@@ -519,14 +522,6 @@ export const VIEW_SCHEMA = {
519
522
  "type": {
520
523
  "kind": "string"
521
524
  },
522
- "required": {
523
- "kind": "array",
524
- "variants": [
525
- {
526
- "kind": "string"
527
- }
528
- ]
529
- },
530
525
  "properties": {
531
526
  "kind": "object",
532
527
  "fields": {
@@ -553,6 +548,14 @@ export const VIEW_SCHEMA = {
553
548
  }
554
549
  }
555
550
  }
551
+ },
552
+ "required": {
553
+ "kind": "array",
554
+ "variants": [
555
+ {
556
+ "kind": "string"
557
+ }
558
+ ]
556
559
  }
557
560
  }
558
561
  }
@@ -573,14 +576,6 @@ export const VIEW_SCHEMA = {
573
576
  "type": {
574
577
  "kind": "string"
575
578
  },
576
- "required": {
577
- "kind": "array",
578
- "variants": [
579
- {
580
- "kind": "string"
581
- }
582
- ]
583
- },
584
579
  "properties": {
585
580
  "kind": "object",
586
581
  "fields": {
@@ -607,6 +602,14 @@ export const VIEW_SCHEMA = {
607
602
  }
608
603
  }
609
604
  }
605
+ },
606
+ "required": {
607
+ "kind": "array",
608
+ "variants": [
609
+ {
610
+ "kind": "string"
611
+ }
612
+ ]
610
613
  }
611
614
  }
612
615
  }
@@ -686,14 +689,6 @@ export const VIEW_SCHEMA = {
686
689
  "type": {
687
690
  "kind": "string"
688
691
  },
689
- "required": {
690
- "kind": "array",
691
- "variants": [
692
- {
693
- "kind": "string"
694
- }
695
- ]
696
- },
697
692
  "properties": {
698
693
  "kind": "object",
699
694
  "fields": {
@@ -720,14 +715,6 @@ export const VIEW_SCHEMA = {
720
715
  "type": {
721
716
  "kind": "string"
722
717
  },
723
- "required": {
724
- "kind": "array",
725
- "variants": [
726
- {
727
- "kind": "string"
728
- }
729
- ]
730
- },
731
718
  "properties": {
732
719
  "kind": "object",
733
720
  "fields": {
@@ -754,6 +741,14 @@ export const VIEW_SCHEMA = {
754
741
  }
755
742
  }
756
743
  }
744
+ },
745
+ "required": {
746
+ "kind": "array",
747
+ "variants": [
748
+ {
749
+ "kind": "string"
750
+ }
751
+ ]
757
752
  }
758
753
  }
759
754
  },
@@ -763,6 +758,14 @@ export const VIEW_SCHEMA = {
763
758
  }
764
759
  }
765
760
  }
761
+ },
762
+ "required": {
763
+ "kind": "array",
764
+ "variants": [
765
+ {
766
+ "kind": "string"
767
+ }
768
+ ]
766
769
  }
767
770
  }
768
771
  }
@@ -914,14 +917,6 @@ export const VIEW_SCHEMA = {
914
917
  "type": {
915
918
  "kind": "string"
916
919
  },
917
- "required": {
918
- "kind": "array",
919
- "variants": [
920
- {
921
- "kind": "string"
922
- }
923
- ]
924
- },
925
920
  "properties": {
926
921
  "kind": "object",
927
922
  "fields": {
@@ -959,6 +954,14 @@ export const VIEW_SCHEMA = {
959
954
  }
960
955
  }
961
956
  }
957
+ },
958
+ "required": {
959
+ "kind": "array",
960
+ "variants": [
961
+ {
962
+ "kind": "string"
963
+ }
964
+ ]
962
965
  }
963
966
  }
964
967
  }
@@ -979,14 +982,6 @@ export const VIEW_SCHEMA = {
979
982
  "type": {
980
983
  "kind": "string"
981
984
  },
982
- "required": {
983
- "kind": "array",
984
- "variants": [
985
- {
986
- "kind": "string"
987
- }
988
- ]
989
- },
990
985
  "properties": {
991
986
  "kind": "object",
992
987
  "fields": {
@@ -1013,6 +1008,14 @@ export const VIEW_SCHEMA = {
1013
1008
  }
1014
1009
  }
1015
1010
  }
1011
+ },
1012
+ "required": {
1013
+ "kind": "array",
1014
+ "variants": [
1015
+ {
1016
+ "kind": "string"
1017
+ }
1018
+ ]
1016
1019
  }
1017
1020
  }
1018
1021
  }
@@ -60,7 +60,7 @@
60
60
  * (the reserved-section owner is unchanged) and never goes through the
61
61
  * extension loader.
62
62
  */
63
- import { Type } from "@earendil-works/pi-ai";
63
+ import { Type } from "typebox";
64
64
  import { defineExtension, defineTool, section, } from "@earendil-works/pi-durable";
65
65
  import { readdirSync, readFileSync } from "node:fs";
66
66
  import { join } from "node:path";
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.11.0] - 2026-11-07
10
+
11
+ - Version-locked bump with the 0.11.0 core line (the pi-durable 1.5.0 release).
12
+
9
13
  ## [0.10.2] - 2026-11-06
10
14
 
11
15
  - Version-locked bump with the 0.10.2 core line (no changes).
@@ -42,6 +42,17 @@ export interface LiveDocWire {
42
42
  readonly compactionFailure?: {
43
43
  readonly error?: unknown;
44
44
  };
45
+ /**
46
+ * Why the newest run ended without an answer (pi-durable 1.5.0
47
+ * `LiveState.runFailure`): set in the commit that ends the run,
48
+ * removed when a later commit starts one. The clients narrow it to
49
+ * `{reason, detail?}` — taskId/inputs/first are the harness's
50
+ * bookkeeping, not display data.
51
+ */
52
+ readonly runFailure?: {
53
+ readonly reason?: unknown;
54
+ readonly detail?: unknown;
55
+ };
45
56
  }
46
57
  /**
47
58
  * The view's `pi.live` doc, or undefined when absent (idle and never-run
@@ -69,3 +80,17 @@ export declare function liveGenerationText(view: ConversationViewBase): string;
69
80
  export declare function liveCompactions(view: ConversationViewBase): number;
70
81
  /** The newest compaction failure's error text, when one is surfaced. */
71
82
  export declare function liveCompactionFailure(view: ConversationViewBase): string | undefined;
83
+ /**
84
+ * The newest run's failure (`pi.live.runFailure`, pi-durable 1.5.0), as
85
+ * the clients display it: `{reason, detail?}`, or undefined when no
86
+ * failure is recorded, when `run` is present (a newer run started — the
87
+ * recorded failure is stale), when the reason is user-caused
88
+ * (`aborted`/`reset` — the user watched themselves do it), or when the
89
+ * doc shape drifted (fail soft to absent, like
90
+ * {@link liveCompactionFailure}). A non-string detail is dropped, never
91
+ * surfaced.
92
+ */
93
+ export declare function liveRunFailure(view: ConversationViewBase): {
94
+ readonly reason: string;
95
+ readonly detail?: string;
96
+ } | undefined;
@@ -80,3 +80,33 @@ export function liveCompactionFailure(view) {
80
80
  const error = failure.error;
81
81
  return typeof error === "string" && error !== "" ? error : undefined;
82
82
  }
83
+ /** The run-failure reasons the USER caused — hidden by design. */
84
+ const USER_CAUSED_RUN_FAILURES = new Set(["aborted", "reset"]);
85
+ /**
86
+ * The newest run's failure (`pi.live.runFailure`, pi-durable 1.5.0), as
87
+ * the clients display it: `{reason, detail?}`, or undefined when no
88
+ * failure is recorded, when `run` is present (a newer run started — the
89
+ * recorded failure is stale), when the reason is user-caused
90
+ * (`aborted`/`reset` — the user watched themselves do it), or when the
91
+ * doc shape drifted (fail soft to absent, like
92
+ * {@link liveCompactionFailure}). A non-string detail is dropped, never
93
+ * surfaced.
94
+ */
95
+ export function liveRunFailure(view) {
96
+ const doc = liveDoc(view);
97
+ if (doc === undefined || doc.run !== undefined)
98
+ return undefined;
99
+ const failure = doc.runFailure;
100
+ if (typeof failure !== "object" || failure === null)
101
+ return undefined;
102
+ const reason = failure.reason;
103
+ if (typeof reason !== "string" || reason === "")
104
+ return undefined;
105
+ if (USER_CAUSED_RUN_FAILURES.has(reason))
106
+ return undefined;
107
+ const detail = failure.detail;
108
+ if (typeof detail === "string" && detail !== "") {
109
+ return { reason, detail };
110
+ }
111
+ return { reason };
112
+ }