@sigrun/sigrun 0.10.1 → 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 (43) hide show
  1. package/CHANGELOG.md +36 -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 +8 -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-Bh9dImMT.js → index-B77MiI5E.js} +2 -2
  40. package/webui/dist/assets/index-BvOKatk4.css +1 -0
  41. package/webui/dist/index.html +2 -2
  42. package/dist/jobs/parked.js +0 -38
  43. package/webui/dist/assets/index-B3T0KKiX.css +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  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
+ ## [0.11.0] - 2026-11-07
9
+
10
+ ### Changed
11
+
12
+ - BREAKING: pi-durable 1.5.0. Conversation data written by this release cannot be read by 0.9.x;
13
+ the first boot under a NEWER pi-durable minor writes `<db>.pre-pi-durable-1.5.0-<stamp>.bak`
14
+ (when no schema-migration snapshot was already taken this boot — a store jumping several
15
+ schema versions snapshots at the schema guard instead); downgrade = restore that file.
16
+ - BREAKING: Transcripts now show the full raw history: turns before a compaction stay above the
17
+ "context compacted" divider.
18
+ - BREAKING: `POST /v1/admin/tasks/:id/retry` removed; a schedule that hits the restart limit
19
+ blocks its job; resume with `POST /v1/jobs/:id/resume`.
20
+ - BREAKING: Durable report metric labels follow pi-durable 1.5.0 (`session_failed` →
21
+ `storage_failed`, `task_parked` removed, `task_interrupted` added); the live-task status label
22
+ `ready` is now `pending`.
23
+ - BREAKING: Every conversation read (SSE connect, `GET /v1/conversations/:id`, extension
24
+ watches) now loads the full history; very long conversations cost more until windowed
25
+ streaming lands.
26
+ - BREAKING: A host killed mid-answer no longer resends the request on restart; the run retries
27
+ per `settings.retry` or ends `interrupted`.
28
+ - BREAKING: Extensions built for pi-durable 1.4 are quarantined with a peer-range message.
29
+
30
+ ## [0.10.2] - 2026-11-06
31
+
32
+ ### Fixed
33
+
34
+ - The multi-select bulk action bar stays on screen: it rendered at the top
35
+ of the list, so ticking rows further down a scrolled list (the escalation
36
+ inbox sidebar) left "Dismiss selected" out of view. It is now sticky to
37
+ the collection's scroll container and shows "N selected".
38
+
39
+ ### Changed
40
+
41
+ - The family bumps to 0.10.2; companion: `@sigrun/ext-gateway` 0.1.21
42
+ (shared peer gains 0.10.2).
43
+
8
44
  ## [0.10.1] - 2026-11-06
9
45
 
10
46
  ### Fixed
package/README.md CHANGED
@@ -978,7 +978,8 @@ backup — are omitted rather than zeroed):
978
978
  | `sigrun_ready` | 1 when the readiness storage check (the root-conversation read `/readyz` gates on) succeeds; 0 when it fails — fires even while the process and the scrape stay up. |
979
979
  | `sigrun_live_runs` | Conversations with a placed-but-unsettled input submission. |
980
980
  | `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). |
981
- | `sigrun_tasks_live{status}` | Live tasks by scheduler state (running/ready/waiting/completing/blocked, 0s included). |
981
+ | `sigrun_tasks_live{status}` | Live tasks by scheduler state (running/pending/waiting/completing/blocked, 0s included). |
982
+ | `sigrun_jobs_blocked{reason}` | Jobs whose record is blocked, by reason (`restart_limit` — the schedule hit pi-durable's restart limit; resume via `POST /v1/jobs/:id/resume`). |
982
983
  | `sigrun_provider_ready{provider,status}` | 1 per provider at its current auth-readiness status (`ready`, `degraded`, `invalid`, `not_ready`, `not_configured`). |
983
984
  | `sigrun_extensions{status}` | Configured extensions by load outcome (`ok` / `quarantined`). |
984
985
  | `sigrun_job_last_run_timestamp_seconds{job,outcome}` | Unix time of each job's last run, by outcome. |
@@ -995,6 +996,7 @@ backup — are omitted rather than zeroed):
995
996
  | `SigrunSubmissionWedged` | `sigrun_inflight_submission_oldest_seconds > 1800` for 5m — a live run is making no progress. |
996
997
  | `SigrunProviderNotReady` | A provider sits at a non-ready auth status for 15m. |
997
998
  | `SigrunJobFailed` | `increase(sigrun_job_runs_total{outcome="failed"}[1h]) > 0` — a scheduled job failed. |
999
+ | `SigrunJobRestartLimited` | `sigrun_jobs_blocked{reason="restart_limit"} > 0` for 5m — a job hit the restart limit and is blocked until resumed (`POST /v1/jobs/:id/resume`; see Blocked jobs). |
998
1000
  | `SigrunBackupStale` | The backup marker is older than 36h while backup is enabled (the rule is absent on an unbacked deploy). |
999
1001
 
1000
1002
  ## Extensions
@@ -1420,32 +1422,30 @@ launcher) is not supervised either; there the callout says "Restart
1420
1422
  sigrun to apply" with no button. `GET /v1/meta` reports the posture
1421
1423
  (`supervised`).
1422
1424
 
1423
- ## Parked tasks
1425
+ ## Blocked jobs
1424
1426
 
1425
- A task cut off repeatedly without progress (a crash loop) is parked by
1426
- pi-durable's restart limit: it shows `blocked` with reason
1427
- `restart_limit` and does not run again until an administrator retries
1428
- it. The `SigrunTaskParked` alert fires on this. Two admin-only
1429
- endpoints inspect and recover parked tasks:
1427
+ A schedule cut off repeatedly without progress (a crash loop) hits
1428
+ pi-durable's restart limit: the run ENDS and the job's record is marked
1429
+ `blocked` with reason `restart_limit`; it does not fire again until
1430
+ someone with the job's write access resumes it. The `SigrunJobRestartLimited` alert fires on
1431
+ this (`sigrun_jobs_blocked{reason="restart_limit"} > 0`).
1430
1432
 
1431
- - `GET /v1/admin/tasks?state=blocked` — lists blocked tasks with
1432
- `{id, kind, reason, conversationId, owner?, extension?}` per row
1433
+ - `POST /v1/jobs/:id/resume` — resumes a restart-blocked job: the
1434
+ record returns to its normal scheduled state and the next fire
1435
+ proceeds. (Admin-only; 409 for a job that isn't blocked.)
1436
+ - `GET /v1/admin/tasks?state=blocked` — lists blocked scheduler tasks
1437
+ with `{id, kind, reason, conversationId, owner?, extension?}` per row
1433
1438
  (owner is the conversation's session owner when it has one; extension
1434
1439
  is the registered task definition's owner when known).
1435
- - `POST /v1/admin/tasks/:id/retry` — re-runs a restart-parked task:
1436
- 204 on success, 404 `unknown_task` for an unknown id, 409
1437
- `not_parked` for a task that isn't parked (including one blocked for
1438
- another reason, such as `missing_task`). A retried schedule job's
1439
- status returns to `waiting` as the task runs again.
1440
1440
 
1441
1441
  ```sh
1442
1442
  # List blocked tasks (admin token).
1443
1443
  curl -H "Authorization: Bearer $TOKEN" \
1444
1444
  "http://localhost:3000/v1/admin/tasks?state=blocked"
1445
1445
 
1446
- # Retry a parked task.
1446
+ # Resume a restart-blocked job.
1447
1447
  curl -X POST -H "Authorization: Bearer $TOKEN" \
1448
- http://localhost:3000/v1/admin/tasks/7/retry
1448
+ http://localhost:3000/v1/jobs/7/resume
1449
1449
  ```
1450
1450
 
1451
1451
  ## API sketch
@@ -1457,8 +1457,9 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \
1457
1457
  differs from boot) and PATCH reports `{applied, pending}`.
1458
1458
  - `POST /v1/admin/restart` — graceful restart (admin, supervised
1459
1459
  deploys only; see Restarting).
1460
- - `GET /v1/admin/tasks?state=blocked`, `POST /v1/admin/tasks/{id}/retry`
1461
- — parked-task inspection and recovery (admin; see Parked tasks).
1460
+ - `GET /v1/admin/tasks?state=blocked`, `POST /v1/jobs/{id}/resume`
1461
+ — blocked-task inspection and blocked-job recovery (admin; see
1462
+ Blocked jobs).
1462
1463
  - `GET /v1/jobs`, `POST /v1/jobs`, `PATCH`/`DELETE /v1/jobs/{id}` —
1463
1464
  the jobs surface (see [Jobs](#jobs)); `POST /v1/jobs/{id}/run` fires
1464
1465
  now; `GET /v1/jobs/{id}/runs` and `GET /v1/jobs/{id}/revisions` are
@@ -285,7 +285,7 @@ export async function driveRun(deps, prepare, opts, parent) {
285
285
  // records; two watches run in ONE listener — (a) this run's tool
286
286
  // results (the maxToolCalls budget), and (b) the PARENT's drift.
287
287
  // Both are synchronous reads of the publication's own changes (the
288
- // subscribeCommits listener contract: no blocking, no Session calls).
288
+ // observeCommits listener contract: no blocking, no Session calls).
289
289
  //
290
290
  // Parent drift, precisely: an ENTRY change on the parent beyond the
291
291
  // start-captured head (a reset/compaction marker or a newly admitted
@@ -310,61 +310,76 @@ export async function driveRun(deps, prepare, opts, parent) {
310
310
  let watcher;
311
311
  const watch = () => {
312
312
  watcher?.();
313
- watcher = harness.subscribeCommits((publication) => {
314
- for (const change of publication.changes) {
315
- if (change.type === "entry") {
316
- if (runId !== undefined &&
317
- change.value.conversationId === runId &&
318
- change.value.kind === ToolResultEntry.kind) {
319
- toolResults += 1;
320
- if (toolResults >= opts.maxToolCalls) {
321
- stop({ kind: "maxToolCalls" });
313
+ const observation = harness.observeCommits({
314
+ advance: (publication) => {
315
+ try {
316
+ for (const change of publication.changes) {
317
+ if (change.type === "entry") {
318
+ if (runId !== undefined &&
319
+ change.value.conversationId === runId &&
320
+ change.value.kind === ToolResultEntry.kind) {
321
+ toolResults += 1;
322
+ if (toolResults >= opts.maxToolCalls) {
323
+ stop({ kind: "maxToolCalls" });
324
+ }
325
+ }
326
+ if (parent !== undefined && start !== undefined) {
327
+ if (change.value.conversationId === parent &&
328
+ change.value.id !== start.head &&
329
+ isAfter(change.value.id, start.head)) {
330
+ stop({
331
+ kind: "parent",
332
+ what: change.value.kind === "pi.reset"
333
+ ? "the conversation was reset or compacted"
334
+ : "a new run was admitted",
335
+ });
336
+ }
337
+ }
338
+ continue;
322
339
  }
323
- }
324
- if (parent !== undefined && start !== undefined) {
325
- if (change.value.conversationId === parent &&
326
- change.value.id !== start.head &&
327
- isAfter(change.value.id, start.head)) {
328
- stop({
329
- kind: "parent",
330
- what: change.value.kind === "pi.reset"
331
- ? "the conversation was reset or compacted"
332
- : "a new run was admitted",
333
- });
340
+ if (parent === undefined || start === undefined)
341
+ continue;
342
+ if (change.type === "submission") {
343
+ if (change.value.conversationId === parent) {
344
+ stop({ kind: "parent", what: "a new run was admitted" });
345
+ }
346
+ continue;
334
347
  }
335
- }
336
- continue;
337
- }
338
- if (parent === undefined || start === undefined)
339
- continue;
340
- if (change.type === "submission") {
341
- if (change.value.conversationId === parent) {
342
- stop({ kind: "parent", what: "a new run was admitted" });
343
- }
344
- continue;
345
- }
346
- if (change.type === "document") {
347
- if (change.conversationId === parent &&
348
- change.record.kind === "sigrun.session-meta" &&
349
- change.value !== null) {
350
- // Compare the COMMITTED VALUE's owner/sharing fields —
351
- // the activity stamper writes `lastActivityAt` on the
352
- // same doc after every settle (a benign change that
353
- // must never cancel a review). SAFETY: the value is
354
- // the adopted JSON revision; the structural read
355
- // touches two optional fields only.
356
- const value = change.value;
357
- if (value.owner !== start.owner ||
358
- sharingKey(value.sharing) !== sharingKey(start.sharing)) {
359
- stop({
360
- kind: "parent",
361
- what: "the parent's owner or sharing changed",
362
- });
348
+ if (change.type === "document") {
349
+ if (change.conversationId === parent &&
350
+ change.record.kind === "sigrun.session-meta" &&
351
+ change.kind === "change") {
352
+ // Compare the COMMITTED VALUE's owner/sharing fields —
353
+ // the activity stamper writes `lastActivityAt` on the
354
+ // same doc after every settle (a benign change that
355
+ // must never cancel a review). SAFETY: the value is
356
+ // the adopted JSON revision; the structural read
357
+ // touches two optional fields only.
358
+ const value = change.value;
359
+ if (value.owner !== start.owner ||
360
+ sharingKey(value.sharing) !== sharingKey(start.sharing)) {
361
+ stop({
362
+ kind: "parent",
363
+ what: "the parent's owner or sharing changed",
364
+ });
365
+ }
366
+ }
363
367
  }
364
368
  }
365
369
  }
366
- }
370
+ catch (error) {
371
+ console.error("curation/runs: commit observer failed", error);
372
+ }
373
+ },
374
+ end: (end) => {
375
+ if (end.kind === "ended")
376
+ return;
377
+ if (end.kind === "failed" && end.error instanceof Error && end.error.message === "session closed")
378
+ return;
379
+ console.error("curation/runs: commit observation failed", end.kind === "failed" ? end.error : end);
380
+ },
367
381
  });
382
+ watcher = () => observation.detach();
368
383
  };
369
384
  watch();
370
385
  const cleanup = () => {
@@ -25,7 +25,7 @@
25
25
  * — the change id for the P8 undo surface and a one-line summary for
26
26
  * the UI.
27
27
  */
28
- import { Type } from "@earendil-works/pi-ai";
28
+ import { Type } from "typebox";
29
29
  import { defineTool } from "@earendil-works/pi-durable";
30
30
  import { OVERLAY_HEADING, withOverlay, } from "./overlays.js";
31
31
  import { curationRun, markViewed } from "./run-doc.js";
@@ -32,7 +32,8 @@
32
32
  * validated candidate shape and a compile-time key assertion, so the
33
33
  * implementation and this contract cannot drift silently.
34
34
  */
35
- import type { AssistantMessage, Provider, TSchema } from "@earendil-works/pi-ai";
35
+ import type { AssistantMessage, Provider } from "@earendil-works/pi-ai";
36
+ import type { TSchema } from "typebox";
36
37
  import type { ConversationDocToken, ConversationId, Cursor, EntryId, EntryQuery, EntryRecord, Extension, InputSubmissionDraft, JsonObject, ModelRef, Page, SubmissionId } from "@earendil-works/pi-durable";
37
38
  import type { Access, Sharing } from "@sigrun/shared/access";
38
39
  import type { DecisionAnswers, DecisionLimits, DecisionRequest, DecisionResult } from "@sigrun/shared/decisions";
@@ -31,7 +31,7 @@
31
31
  * bind, the seam carries the events to in-process listeners only — the
32
32
  * default subscriber set is empty, so nothing is lost by not wiring it.
33
33
  */
34
- import { Type } from "@earendil-works/pi-ai";
34
+ import { Type } from "typebox";
35
35
  import { defineExtension, defineTool, } from "@earendil-works/pi-durable";
36
36
  import { applyOps, DataOpError, toText } from "@sigrun/shared/blocks";
37
37
  import { validateData, validateTree } from "../../blocks/validate.js";
@@ -14,11 +14,11 @@ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite
14
14
  import { DECISION_TIMEOUT_ENV, effectiveMethods } from "../config.js";
15
15
  import { requiresProxyVouch } from "../server/auth/proxy-only.js";
16
16
  import { wireActivity } from "./activity.js";
17
- import { snapshotBeforeMigration } from "./migration-snapshot.js";
17
+ import { snapshotBeforeDurableUpgrade, snapshotBeforeMigration, writeDurableVersionMarker, } from "./migration-snapshot.js";
18
18
  import { createScheduleTask } from "../jobs/task.js";
19
19
  import { createJobsService, jobWireOf } from "../jobs/index.js";
20
20
  import { reconcileJobs } from "../jobs/reconcile.js";
21
- import { applyParkedJobs } from "../jobs/parked.js";
21
+ import { markRestartLimitedJob } from "../jobs/restart-limit.js";
22
22
  import { createDurableReports } from "./reports.js";
23
23
  import { registerJobObservers } from "../jobs/observe.js";
24
24
  import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
@@ -372,12 +372,33 @@ export async function openHarness(config, options = {}) {
372
372
  // openNodeSqliteStorage applies its one-way durable migration. The
373
373
  // injected clock keeps snapshots deterministic in tests; a needed
374
374
  // snapshot failure aborts boot here, before any migration write.
375
- await snapshotBeforeMigration(config.storagePath, {
375
+ const schemaSnapshot = await snapshotBeforeMigration(config.storagePath, {
376
376
  env,
377
377
  now: options.now ?? (() => new Date()),
378
378
  log: (line) => console.log(line),
379
379
  });
380
- return await openNodeSqliteStorage(config.storagePath);
380
+ // D4 (1.5.0 upgrade): a store last written by an older pi-durable
381
+ // minor gets its own snapshot — skipped when the schema guard above
382
+ // already wrote one this boot. The version marker is written only
383
+ // AFTER a successful open, so it never claims a version the store
384
+ // has not actually been opened by.
385
+ if (schemaSnapshot === undefined) {
386
+ await snapshotBeforeDurableUpgrade(config.storagePath, {
387
+ env,
388
+ now: options.now ?? (() => new Date()),
389
+ log: (line) => console.log(line),
390
+ });
391
+ }
392
+ const opened = await openNodeSqliteStorage(config.storagePath);
393
+ try {
394
+ await writeDurableVersionMarker(config.storagePath);
395
+ }
396
+ catch (error) {
397
+ // A failed stamp must not leak the open storage handle.
398
+ await opened.close(BACKGROUND_CONTEXT).catch(() => { });
399
+ throw error;
400
+ }
401
+ return opened;
381
402
  })();
382
403
  const registry = createRegistry();
383
404
  // Late-filled by the loader: factories publish through the same registry
@@ -1192,7 +1213,7 @@ export async function openHarness(config, options = {}) {
1192
1213
  // the jobs tests' time-injection lever).
1193
1214
  const clockNow = options.now ?? (() => new Date());
1194
1215
  // D5: reports may arrive inside Harness.open. The collector logs/counts
1195
- // synchronously and queues parks; job patches drain after binding below.
1216
+ // synchronously (no durable follow-up work from a report survives 1.5.0).
1196
1217
  const reports = createDurableReports({
1197
1218
  log: (line) => console.error(line),
1198
1219
  onReport: options.onReport,
@@ -1256,6 +1277,11 @@ export async function openHarness(config, options = {}) {
1256
1277
  entry.name !== CURATION_GATE_EXTENSION);
1257
1278
  },
1258
1279
  toolExecution: callerSettings?.toolExecution,
1280
+ // pi-durable 1.5.0: restart-limited tasks END instead of parking
1281
+ // (no file backing — caller seam and default only, like
1282
+ // toolExecution). The battery pins it to make crash outcomes
1283
+ // deterministic.
1284
+ restartLimit: callerSettings?.restartLimit,
1259
1285
  compaction: {
1260
1286
  ...config.compaction,
1261
1287
  ...(config.compaction.modelOverrides === undefined &&
@@ -1282,9 +1308,8 @@ export async function openHarness(config, options = {}) {
1282
1308
  jobRuntimeDeps.harness = harness;
1283
1309
  jobRuntimeDeps.storage = storage;
1284
1310
  jobRuntimeDeps.interactiveTools = new Set(loadedInteractiveTools);
1285
- // Drain the restart-limit parks reported during open before scheduling
1286
- // resumes. A failed status patch is logged and dropped by applyParkedJobs.
1287
- await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
1311
+ // pi-durable 1.5.0 removed task_parked/retryTask (the parked-job drain
1312
+ // existed for that pair); Task 4 wires the replacement recovery surface.
1288
1313
  // The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
1289
1314
  // recovery settles (faulted/orphaned submissions the scheduler drains)
1290
1315
  // are observed like any other commit. Extension handlers register under
@@ -1437,14 +1462,28 @@ export async function openHarness(config, options = {}) {
1437
1462
  // open, and a no-op once the queue is found empty. Events enqueued
1438
1463
  // while a drain is already pumping are picked up by that same loop.
1439
1464
  let drainTimer;
1440
- harness.subscribeCommits(() => {
1441
- if (drainTimer !== undefined)
1442
- return;
1443
- drainTimer = setTimeout(() => {
1444
- drainTimer = undefined;
1445
- void eventBus.drain(hookDeadlineMs, BACKGROUND_CONTEXT);
1446
- });
1447
- drainTimer.unref?.();
1465
+ harness.observeCommits({
1466
+ advance: () => {
1467
+ try {
1468
+ if (drainTimer !== undefined)
1469
+ return;
1470
+ drainTimer = setTimeout(() => {
1471
+ drainTimer = undefined;
1472
+ void eventBus.drain(hookDeadlineMs, BACKGROUND_CONTEXT);
1473
+ });
1474
+ drainTimer.unref?.();
1475
+ }
1476
+ catch (error) {
1477
+ console.error("boot: commit observer failed", error);
1478
+ }
1479
+ },
1480
+ end: (end) => {
1481
+ if (end.kind === "ended")
1482
+ return;
1483
+ if (end.kind === "failed" && end.error instanceof Error && end.error.message === "session closed")
1484
+ return;
1485
+ console.error("boot: commit observation failed", end.kind === "failed" ? end.error : end);
1486
+ },
1448
1487
  });
1449
1488
  // A failed Session closes itself (the upstream contract): the bus's
1450
1489
  // sweep + subscription must not outlive it. shutdownSequence (the
@@ -1528,19 +1567,35 @@ export async function openHarness(config, options = {}) {
1528
1567
  // the counter feeds `sigrun_job_runs_total{outcome}`). A creation
1529
1568
  // write of an already-settled run (the overlap skip, the catch-up
1530
1569
  // skip) counts once at creation; a `running` run counts at its settle;
1531
- // prune retirements (value null) never count.
1570
+ // prune retirements (tagged `kind === "retire"`) never count.
1532
1571
  const jobRunTotals = new Map();
1533
- harness.subscribeCommits((publication) => {
1534
- for (const change of publication.changes) {
1535
- if (change.type !== "document")
1536
- continue;
1537
- if (change.record.kind !== JobRunDoc.definition.kind)
1538
- continue;
1539
- const outcome = change.value?.outcome;
1540
- if (typeof outcome !== "string" || outcome === "running")
1541
- continue;
1542
- jobRunTotals.set(outcome, (jobRunTotals.get(outcome) ?? 0) + 1);
1543
- }
1572
+ harness.observeCommits({
1573
+ advance: (publication) => {
1574
+ try {
1575
+ for (const change of publication.changes) {
1576
+ if (change.type !== "document")
1577
+ continue;
1578
+ if (change.kind !== "change")
1579
+ continue;
1580
+ if (change.record.kind !== JobRunDoc.definition.kind)
1581
+ continue;
1582
+ const outcome = change.value?.outcome;
1583
+ if (typeof outcome !== "string" || outcome === "running")
1584
+ continue;
1585
+ jobRunTotals.set(outcome, (jobRunTotals.get(outcome) ?? 0) + 1);
1586
+ }
1587
+ }
1588
+ catch (error) {
1589
+ console.error("boot: commit observer failed", error);
1590
+ }
1591
+ },
1592
+ end: (end) => {
1593
+ if (end.kind === "ended")
1594
+ return;
1595
+ if (end.kind === "failed" && end.error instanceof Error && end.error.message === "session closed")
1596
+ return;
1597
+ console.error("boot: commit observation failed", end.kind === "failed" ? end.error : end);
1598
+ },
1544
1599
  });
1545
1600
  jobs = createJobsService({
1546
1601
  harness,
@@ -1585,6 +1640,17 @@ export async function openHarness(config, options = {}) {
1585
1640
  console.warn(`jobs: declared job "${spec.name}" names owner "${spec.owner}" who is not in the user directory — it runs ownerless until its source is edited`);
1586
1641
  }
1587
1642
  }
1643
+ // D2 restart-limit recovery: reports that arrived DURING
1644
+ // Harness.open sat queued in the collector. Register the live
1645
+ // listener now (drains the backlog via setImmediate, then every
1646
+ // later restart-limited report flows straight to it); the listener
1647
+ // owns all Harness reads/commits — the collector never calls the
1648
+ // Harness from inside onReport.
1649
+ reports.onRestartLimited((task) => {
1650
+ void markRestartLimitedJob(harness, task, BACKGROUND_CONTEXT).catch((error) => {
1651
+ console.error("jobs: restart-limited listener failed:", error);
1652
+ });
1653
+ });
1588
1654
  harness.resume();
1589
1655
  return {
1590
1656
  harness,
@@ -18,7 +18,7 @@
18
18
  * `structuredOutput` under the declared schema (pi-durable rejects
19
19
  * structuredOutput without one).
20
20
  */
21
- import { Type } from "@earendil-works/pi-ai";
21
+ import { Type } from "typebox";
22
22
  import { defineExtension, defineTool } from "@earendil-works/pi-durable";
23
23
  import { validateDecisionRequest } from "./decisions.js";
24
24
  /** The core extension's reserved slug (boot.ts keeps the core slugs). */
@@ -10,7 +10,7 @@
10
10
  * `ok:false` with one of the spec's codes. Error text never carries the
11
11
  * credential (scrubbed) nor the submitted request.
12
12
  */
13
- import { Type } from "@earendil-works/pi-ai";
13
+ import { Type } from "typebox";
14
14
  import { Compile } from "typebox/compile";
15
15
  import { DECISION_MAX_BYTES, DECISION_MAX_QUESTIONS, } from "@sigrun/shared/decisions";
16
16
  // A strict-JSON value schema. `Type.Unknown()` leaves a node unchecked;
@@ -3,7 +3,7 @@
3
3
  * side lane + idle detection + shutdown sequencing. See the memory-seam
4
4
  * spec §"Design".
5
5
  *
6
- * The bus subscribes to pi-durable's `Session.subscribeCommits` (sigrun's
6
+ * The bus subscribes to pi-durable's `Session.observeCommits` (sigrun's
7
7
  * harness IS a Session) and reduces each `CommitPublication` into the
8
8
  * memory seam's host events — settled-run groups and conversation
9
9
  * lifecycle — without touching run logic. The upstream listener contract
@@ -13,10 +13,10 @@
13
13
  * deadline-bounded (`Promise.race` against `hookDeadlineMs`), rejections
14
14
  * are caught + logged, and overflow drops the OLDEST queued event.
15
15
  *
16
- * Detection rules (verified against pi-durable 1.2.0's publication shapes
16
+ * Detection rules (verified against pi-durable 1.5.0's publication shapes
17
17
  * — table changes are the raw `StorageWrite` records; document changes are
18
18
  * `DocumentCommitChange`s whose `record.createdAt === publication.seq`
19
- * marks creation, `value === null` marks retirement):
19
+ * marks creation, the tagged `kind === "retire"` marks retirement):
20
20
  *
21
21
  * - **Settle**: a `submission` table write with `value.type === "input"`
22
22
  * and a terminal status (`done`/`unanswered`). Write-type submissions
@@ -171,9 +171,19 @@ export class EventBus {
171
171
  if (options.onHostEvent !== undefined) {
172
172
  this.onHostEvent(options.onHostEvent);
173
173
  }
174
- this.#unsubscribe = harness.subscribeCommits((publication) => {
175
- this.observe(publication);
174
+ const observation = harness.observeCommits({
175
+ advance: (publication) => {
176
+ this.observe(publication);
177
+ },
178
+ end: (end) => {
179
+ if (end.kind === "ended")
180
+ return;
181
+ if (end.kind === "failed" && end.error instanceof Error && end.error.message === "session closed")
182
+ return;
183
+ console.error("event-bus: commit observation failed", end.kind === "failed" ? end.error : end);
184
+ },
176
185
  });
186
+ this.#unsubscribe = () => observation.detach();
177
187
  // The idle sweep is host-owned machinery (never an extension factory
178
188
  // concern — the twill lesson) and binds to THIS bus's lifetime: close
179
189
  // and shutdownSequence both clear it. Unref'd so a booted-but-idle
@@ -707,7 +717,7 @@ export class EventBus {
707
717
  continue;
708
718
  if (change.conversationId === undefined)
709
719
  continue;
710
- this.#internal.set(change.conversationId, change.value?.internal === true);
720
+ this.#internal.set(change.conversationId, change.kind === "change" && change.value.internal === true);
711
721
  }
712
722
  for (const change of publication.changes) {
713
723
  if (change.type !== "conversation")
@@ -758,9 +768,9 @@ export class EventBus {
758
768
  // their host events here (ordinary document changes only — a
759
769
  // fork's document.copy carries no status transition).
760
770
  if (change.type === "document" &&
771
+ change.kind === "change" &&
761
772
  kind === InteractionDoc.definition.kind &&
762
- change.conversationId !== undefined &&
763
- change.value !== null) {
773
+ change.conversationId !== undefined) {
764
774
  const record = change.value;
765
775
  if (typeof record.callId === "string" &&
766
776
  typeof record.kind === "string" &&
@@ -874,7 +884,7 @@ export class EventBus {
874
884
  this.#metaBasis.set(conversationId, childBasis);
875
885
  continue;
876
886
  }
877
- if (change.value === null) {
887
+ if (change.type === "document" && change.kind === "retire") {
878
888
  this.#archivedBasis.delete(conversationId);
879
889
  this.#metaBasis.delete(conversationId);
880
890
  continue;
@@ -1262,8 +1262,9 @@ const DECLARED_PEER_MODULES = [
1262
1262
  "@earendil-works/pi-ai",
1263
1263
  ];
1264
1264
  /**
1265
- * The declared-peer guard (pi-durable 1.4.0 D7): after the resolved-version
1266
- * guard, reject an Earendil peer range that excludes the running host.
1265
+ * The declared-peer guard (pi-durable 1.4.0 D7; 1.5.0 D3 moved it
1266
+ * PRE-import): reject an Earendil peer range that excludes the running
1267
+ * host before the extension's module code executes.
1267
1268
  * Non-semver specifiers (file:, link:, git:, URLs, workspace:) and wildcards
1268
1269
  * pass; a missing or unreadable manifest also passes because the guard never
1269
1270
  * quarantines what it cannot read.
@@ -1843,6 +1844,17 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1843
1844
  if (/\.ts$/.test(loaded)) {
1844
1845
  throw new Error(`the resolved entry is a TypeScript source (${loaded}) — build the package and ship its built entry (dist/index.js); the built server loads JavaScript`);
1845
1846
  }
1847
+ // Declared-peer preflight (1.5.0 upgrade, spec D3): the peer-range
1848
+ // guard runs BEFORE the import so a previous-minor extension's
1849
+ // module-level code (which may destructure host shapes the new
1850
+ // version removed) never executes. A throw here rides the same
1851
+ // per-extension try as every other quarantine.
1852
+ const moduleDir = extensionModuleDir(loaded);
1853
+ if (moduleDir !== undefined) {
1854
+ const peerReason = checkDeclaredPeers(moduleDir);
1855
+ if (peerReason !== undefined)
1856
+ throw new Error(peerReason);
1857
+ }
1846
1858
  const module = (await import(loaded));
1847
1859
  // Host-module version guard (spec §6.4 R6): the module tree the
1848
1860
  // extension's bare imports resolve from must agree with the copy
@@ -1851,12 +1863,8 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1851
1863
  // no trace (no seam host, no bindings, no registry entry). A throw
1852
1864
  // here rides the existing per-extension try (quarantine, boot
1853
1865
  // continues) exactly like the reserved-section check below.
1854
- const moduleDir = extensionModuleDir(loaded);
1855
1866
  if (moduleDir !== undefined) {
1856
1867
  checkHostModules(moduleDir);
1857
- const peerReason = checkDeclaredPeers(moduleDir);
1858
- if (peerReason !== undefined)
1859
- throw new Error(peerReason);
1860
1868
  }
1861
1869
  // Two-phase host: the factory receives the host at CALL time, but
1862
1870
  // the extension name and kinds it declares are only known from its