pi-durable-subagents 1.0.22 → 1.0.24

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.24
4
+
5
+ - `events --all [--since <cursor>] [--limit <n>]`: one durable log of
6
+ milestones across all workflows (`submitted`, `started`, `asking` with the
7
+ full question, `answered` without the text, `sealed` with status and data,
8
+ `fenced` with the reason of an interruption, `workflow-done`), read with an
9
+ `<epoch>:<seq>` cursor in pages of at most 1000. Delivery is at least once
10
+ without gaps, also across `kill -9` and restarts (re-derived events keep
11
+ their `id`); events are kept at least 7 days and never while their workflow
12
+ is unfinished or asking; an older cursor gets `cursor-expired` (exit 4). A
13
+ prune whose events cannot be logged first is rejected (`event-log: …`).
14
+ - `run --labels <json>` (tool: `labels`): caller labels, part of the spec
15
+ digest, returned by `describe` and echoed on every event of the run.
16
+ - Why a call does not move (R7): `describe` adds `reason`, `detail` and
17
+ `since` to each waiting call, and the event log gets `waiting`/`moving`
18
+ when the reason changes: `unconfirmed-stop`, `provider-exhausted`,
19
+ `writer-lock`, `lease`, `slot`, `silent`.
20
+ - README: the subagent force-restart guard is a rail against accidents, not a
21
+ security boundary.
22
+
23
+ ## 1.0.23
24
+
25
+ - An asker cut off by a restart (also `restart --force`) now hibernates and
26
+ resumes with the answer, as documented. A graceful shutdown let its `ask`
27
+ end with its own "Session shut down" (or "Ask aborted") error before the
28
+ process exited; recovery took that for an answered ask, recorded a loss and
29
+ ran the call again, so the model asked a second time, `describe` showed a
30
+ `lastFence` and the stale question stayed listed. Only a killed process (no
31
+ result at all) was recognised before.
32
+
3
33
  ## 1.0.22
4
34
 
5
35
  - The installed CLI runs `run`, `send` and `stop` again: 1.0.21 loaded the
package/README.md CHANGED
@@ -235,12 +235,14 @@ pi-durable-subagents smoke check this machine and this pi (offline,
235
235
  pi-durable-subagents chaos run the fault suite (offline, about 2 minutes)
236
236
  pi-durable-subagents status [wid] [--json]
237
237
  pi-durable-subagents events <wid> [--json] the meaningful timeline of one workflow
238
+ pi-durable-subagents events --all [--since <cursor>] [--limit <n>] [--json]
239
+ milestones of every workflow, read with a cursor (see below)
238
240
  pi-durable-subagents tail [wid] [--json]
239
241
  pi-durable-subagents start start the orchestrator if work is pending; sends nothing
240
242
  pi-durable-subagents resume [wid] continue unfinished or parked work (undoes drain / stop-all)
241
243
  pi-durable-subagents drain hold existing workflows: running calls finish, nothing new starts in them
242
244
  pi-durable-subagents stop <wid|call>
243
- pi-durable-subagents run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]
245
+ pi-durable-subagents run --request <id> --spec <file|-> [--labels <json>] [--cwd <dir>] [--json] [--wait-ms <n>]
244
246
  start a run under a caller-chosen id; safe to retry (see below)
245
247
  pi-durable-subagents send --request <id> --to <run-id|wid/key> --kind follow-up|answer|steer|model
246
248
  [--call <key>] [--qid <qid> --rev <n>] --message <text|@file> [--model <m>] [--json]
@@ -310,8 +312,9 @@ full text, `qid`, `rev` and the `to` address to answer), `sealed` (finished:
310
312
  `status` plus every call's unclipped `output`, `error` and schema `data`) or
311
313
  `pruned` (`pruned: {status, endedAt}`; workflows pruned before 1.0.21 have
312
314
  only `endedAt`), with `wid`, `request` and
313
- `spec_digest`. Live calls also show what they wait for (slot, writer lock,
314
- lease, exhausted provider). `lastFence: {at, exec, reason}` appears only
315
+ `spec_digest`, and `labels` when the run has any. Live calls also show what
316
+ they wait for (slot, writer lock, lease, exhausted provider; see "Labels and
317
+ why a call does not move"). `lastFence: {at, exec, reason}` appears only
315
318
  when an execution was cut off: it had not ended its turn when it was fenced,
316
319
  did not hibernate on a question (also when recovery finds it cut off while only
317
320
  its question's `ask` ran), and was not ended on purpose (stop, timeout,
@@ -329,6 +332,115 @@ pi-durable-subagents send --request build-42-a1 --to build-42 --kind answer \
329
332
  --qid <qid> --rev <rev> --message "yes"
330
333
  ```
331
334
 
335
+ #### Events across workflows
336
+
337
+ `events --all` reads one durable log of milestones of every workflow
338
+ (`$DSA_HOME/events.jsonl`, written only by the orchestrator), so a program
339
+ without a daemon can poll it and react to completions and questions without
340
+ reading every workflow. Output is JSON lines (with or without `--json`).
341
+
342
+ ```sh
343
+ pi-durable-subagents events --all # {"head":"<epoch>:<seq>","more":false}
344
+ pi-durable-subagents events --all --since <cursor> --limit 500
345
+ ```
346
+
347
+ Every event has `id`, `cursor`, `ts` (when the milestone happened), `type`,
348
+ `wid`, `request` (the run id, when the run was created by `run --request`),
349
+ `labels` (the run's labels, when it has any), and for call events `key`,
350
+ `gen` and `call` (`<wid>@<rev>/<key>@<gen>`):
351
+
352
+ | type | fields |
353
+ | --- | --- |
354
+ | `submitted` | `name?` — the workflow was created |
355
+ | `started` | `exec` — the first execution of a call (generation) began |
356
+ | `asking` | `qid`, `rev`, `question` (full text), `to` (`<wid>/<key>`, the answer address) |
357
+ | `answered` | `qid`, `rev`, `by`, `via?`, `digest` (sha256 hex of the UTF-8 answer), `length` (its length in UTF-16 code units, as JavaScript counts) — never the text; `describe` has it |
358
+ | `sealed` | `status` (`ok`, `failed`, `gate-failed`, `stopped`, `timeout`, `budget`, `unknown`, …), `error?` (unclipped), `data` when its JSON is at most 16 KiB, else `data_omitted: <bytes>` (read it with `describe`) |
359
+ | `fenced` | `exec` (the execution cut off), `reason` (`restart-force`, `orchestrator-crash`, `process-died`), `at` — an execution was interrupted and the call resumed in a new one: the processes its tools had started are gone |
360
+ | `workflow-done` | `status`, `error?` |
361
+
362
+ Readers must ignore types they do not know (`waiting`/`moving` follow).
363
+ `by` is the sender of the answer: `session:<id>` for a pi session (with
364
+ `via: "ui"` when it came from the subagent list), `cli:<user>@<host>` for the
365
+ CLI (a subagent answering through the CLI also shows as `cli:…`), else
366
+ `unknown`. `fenced` is emitted when the call's next execution begins (right
367
+ after recovery, before it waits for a slot) and only when the fence
368
+ interrupted work, exactly as `describe`'s `lastFence`: a turn that had ended,
369
+ a hibernated question, an answer's resume or a seal are no `fenced`. A `once`
370
+ call cut off in a tool is never resumed: it gets `sealed` with status
371
+ `unknown` and no `fenced`.
372
+
373
+ Cursors are `<epoch>:<seq>`; `--since c` returns the events after `c` in log
374
+ order, at most `--limit` (default and maximum 1000), then
375
+ `{"head": …, "more": …}`. With `more: true`, `head` is the cursor of the last
376
+ event printed: pass it as the next `--since`. With `more: false`, `head` is
377
+ the log's head; it may name a seq that no event has (every orchestrator start
378
+ skips 1000 seqs, so a seq you saw in a write that a power cut undid is never
379
+ reused), and it is still a valid cursor. Without `--since` only the head is
380
+ printed. When no log exists yet, the command starts the orchestrator (which
381
+ creates it from everything still on disk) and waits up to `--wait-ms`
382
+ (default 60 s), else prints `{"pending": true}` and exits 75; when the log
383
+ exists it never starts anything.
384
+
385
+ Delivery is at least once, without gaps: after a crash the orchestrator
386
+ derives again from its last durable watermark, and an event derived again has
387
+ the same `id` (a new cursor). Deduplicate by `id`, and persist your cursor
388
+ only after you applied the events of a page.
389
+
390
+ Retention: an event is dropped only when it was logged more than 7 days ago
391
+ (`"k": { "eventRetentionMs": … }` in `config.json`) and its workflow is
392
+ finished in its current revision (done, failed or stopped — not parked) with
393
+ no open question and no unsealed call, or was pruned. The log is compacted at
394
+ orchestrator start and at most hourly. A cursor of another epoch (the log was
395
+ replaced: a corrupt log is kept aside as `events.jsonl.corrupt-<ms>` and a new
396
+ one starts), below the highest dropped seq, or beyond the head gets exit 4
397
+ and one line `{"error":"cursor-expired","head":"…","oldest":"…"}` (`oldest`
398
+ is the smallest cursor still accepted). To recover, run `describe --key` for
399
+ every run you have not closed (it reports `sealed`, `asking` with the full
400
+ question, `pruned`, …), rebuild your state from those answers, then continue
401
+ with `--since <head>` from that reply. A malformed cursor or option exits 1
402
+ with `{"error":"invalid-arguments","message":…}`.
403
+
404
+ #### Labels and why a call does not move
405
+
406
+ `run --request <id> --spec <file> --labels '{"node":"n1","attempt":"2"}'`
407
+ (tool: `labels: {…}`) attaches your own labels to a run: a flat JSON object
408
+ of at most 32 keys `[A-Za-z0-9_.:-]{1,64}` with string values of at most 256
409
+ characters, at most 4096 bytes of JSON. They are part of the content: the
410
+ same id with other labels (or none) is a `request-conflict` (exit 3). Give
411
+ them only with `--labels`; a `labels` field in the spec file is refused.
412
+ Invalid labels exit 1 and submit nothing, and the orchestrator rejects a
413
+ request that carries invalid ones (`invalid-labels: …`). `describe` returns
414
+ them as `labels` (also after `prune`), and every event of the run carries
415
+ them.
416
+
417
+ When an unsealed call does not move, its `waiting` in `describe` adds
418
+ `reason`, `detail` (the status line for that cause, e.g. `waiting for a slot:
419
+ probe 1/1`) and `since` (ms: when that cause started). The event log has the
420
+ same: `waiting {reason, detail, since}` when the reason appears or changes,
421
+ `moving {after}` when it clears (also when the call ends), checked every
422
+ `k.r7Ms` (default 5 s; read when the orchestrator starts, unlike the other
423
+ `k` settings a `config.json` change does not apply it until a restart); a
424
+ change of detail alone is no event. The first reason
425
+ that applies wins:
426
+
427
+ | reason | the call … |
428
+ | --- | --- |
429
+ | `unconfirmed-stop` | had processes that did not exit after SIGKILL; it starts nothing until they are gone (look at them) |
430
+ | `provider-exhausted` | runs on, or can only be admitted to, providers whose usage window is used up |
431
+ | `writer-lock` | waits for another call that writes in the same worktree |
432
+ | `lease` | waits for a resource lease (`hold`, below) |
433
+ | `slot` | is queued for a provider slot or memory headroom (at once when its providers are full, else after 3 s) |
434
+ | `silent` | is running (launched, not stopped) without visible activity: the stall notice of that execution, with the command running and for how long |
435
+
436
+ A call whose current execution asked a question (also while it hibernates
437
+ until the answer) is `asking`, not waiting. Once the answer arrives the call
438
+ launches again, and from then on it waits like any call (for a slot, the
439
+ writer lock, …) even though `describe` still lists the question as open until
440
+ the new execution reads it (its `state` stays `asking`). A drained call
441
+ (no execution running) is never `silent`; a sealed call never waits. `provider-exhausted`, `writer-lock`, `lease` and `slot` are queues
442
+ that clear by themselves; `silent` and `unconfirmed-stop` may need a look.
443
+
332
444
  ### Housekeeping
333
445
 
334
446
  Journals are never compacted, so state only grows. `prune` removes finished
@@ -481,7 +593,11 @@ pi-durable-subagents restart --force <token> --reason "<why>"
481
593
  A changed execution set is refused with a fresh list and token. With no live
482
594
  executions no token is needed. Bare force cannot fence live executions, and the
483
595
  tool rejects `force:true`. Subagents cannot force a restart, even from bash:
484
- it would fence themselves and other sessions' work. Force fences running
596
+ it would fence themselves and other sessions' work. That guard reads the
597
+ environment on purpose (`DSA_EXEC`/`DSA_CALL` and the request's initiator
598
+ call): it is a rail against accidents and instructions, not a security
599
+ boundary — a subagent runs as the same OS user and could signal the
600
+ orchestrator anyway. Force fences running
485
601
  executions; they resume on the new version from their sessions, like after a
486
602
  crash, so a tool call that was running is repeated or reported as interrupted.
487
603
  The restart ledger records the reason and initiator; after the next start,
@@ -1,7 +1,7 @@
1
1
  import { watch } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { Type } from '@earendil-works/pi-ai';
4
- import { CT, ENV, JT } from "../types.js";
4
+ import { ASK_CUT, CT, ENV, JT } from "../types.js";
5
5
  import { readJournalSnapshot } from "../kernel/journal.js";
6
6
  import { scanInbox } from "../kernel/mailbox.js";
7
7
  import { contentHash } from "../kernel/ids.js";
@@ -250,7 +250,7 @@ export function registerChild(pi) {
250
250
  pi.on('session_shutdown', async () => {
251
251
  active = false;
252
252
  watcher?.close();
253
- blocked?.reject(new Error('Session shut down'));
253
+ blocked?.reject(new Error(ASK_CUT.shutdown));
254
254
  blocked = undefined;
255
255
  await queue;
256
256
  });
@@ -263,12 +263,12 @@ export function registerChild(pi) {
263
263
  // Attach immediately so abort during the queued intake never produces an unhandled rejection.
264
264
  void result.catch(() => { });
265
265
  const abort = () => { void serial(async () => { if (blocked?.resolve === resolve)
266
- blocked = undefined; reject(new Error('Ask aborted')); }); };
266
+ blocked = undefined; reject(new Error(ASK_CUT.aborted)); }); };
267
267
  signal?.addEventListener('abort', abort, { once: true });
268
268
  try {
269
269
  await serial(async () => {
270
270
  if (!active || signal?.aborted)
271
- throw new Error('Ask aborted');
271
+ throw new Error(ASK_CUT.aborted);
272
272
  if (blocked)
273
273
  throw new Error('Another question is already blocked');
274
274
  const qid = contentHash({ exec, question }), rev = (state.questions.get(qid)?.rev ?? 0) + 1;
@@ -16,10 +16,11 @@ export const parameters = Type.Object({
16
16
  usageBudget: Type.Optional(Type.Object({ tokens: Type.Optional(Type.Number()), costUsd: Type.Optional(Type.Number()) })),
17
17
  maxCalls: Type.Optional(Type.Integer({ minimum: 1 })), inputs: Type.Optional(Type.Record(Type.String(), Type.String())),
18
18
  name: Type.Optional(Type.String()),
19
+ labels: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "run: your labels, e.g. {node, attempt}: at most 32 keys [A-Za-z0-9_.:-]{1,64}, string values of at most 256 characters, 4096 bytes of JSON; part of the request's content (spec_digest), shown by describe and on its events." })),
19
20
  timeoutMs: Type.Optional(Type.Number({ description: "Per-call limit on active time in milliseconds (a number). Omit unless a hard limit is needed; prefer budgets." })),
20
21
  key: Type.Optional(Type.String({ description: "A single agent/task run: the call's key. status with wid: that call's full result." })),
21
22
  full: Type.Optional(Type.Boolean({ description: "status: with wid, the complete workflow detail including every output." })),
22
- force: Type.Optional(Type.Union([Type.String(), Type.Boolean()], { description: "restart: the token shown by a refusal. Show the user the list and obtain explicit approval first; boolean true is refused. Subagents cannot force a restart." })),
23
+ force: Type.Optional(Type.Union([Type.String(), Type.Boolean()], { description: "restart: the token shown by a refusal. Show the user the list and obtain explicit approval first; boolean true is refused. Subagents cannot force a restart (an environment-based rail against accidents, not a security boundary)." })),
23
24
  reason: Type.Optional(Type.String({ description: "restart: non-empty reason, at most 500 characters; required with force." })),
24
25
  request: Type.Optional(Type.String({ description: "run/send/stop: your own request id (1-124 chars [A-Za-z0-9][A-Za-z0-9._:-]*) making a retry safe: the same id with the same content gets the first outcome; other content is refused (request-conflict)." })),
25
26
  }, { additionalProperties: true });
@@ -2,6 +2,7 @@ import { restartInputError } from "../../orchestrator/restart.js";
2
2
  import { resolve } from "node:path";
3
3
  import { validateCallSpec } from "../../compat/spec.js";
4
4
  import { compileFanout } from "../../compat/fanout.js";
5
+ import { checkLabels } from "../../events/labels.js";
5
6
  /** Call fields a tasks/chain run applies to every step that does not set its own. */
6
7
  export const stepDefaults = ["model", "timeoutMs", "budget", "isolation", "context", "tools", "skills", "once", "writer"];
7
8
  function string(args, name) {
@@ -45,7 +46,7 @@ export function request(args, cwd) {
45
46
  if (typeof action !== "string" || !action)
46
47
  throw new Error("action is required: run, agents, send, stop, revise, status, resume, drain, restart");
47
48
  if (action === "run") {
48
- const { action: _, workflow, source, tasks, chain, args: inputs, name, usageBudget, maxCalls, inputs: files, by: _by, request: _request, ...spec } = args;
49
+ const { action: _, workflow, source, tasks, chain, args: inputs, name, usageBudget, maxCalls, inputs: files, labels, by: _by, request: _request, ...spec } = args;
49
50
  const choices = [workflow, source, tasks, chain, spec.agent === undefined && spec.task === undefined ? undefined : spec];
50
51
  if (choices.filter(v => v !== undefined).length !== 1)
51
52
  throw new Error("run requires exactly one of workflow, source, tasks, chain, or agent/task");
@@ -84,6 +85,9 @@ export function request(args, cwd) {
84
85
  body.args = inputs;
85
86
  if (name !== undefined)
86
87
  body.name = string(args, "name");
88
+ // R6: part of the spec digest; an empty object is the same as none.
89
+ if (labels !== undefined && Object.keys(checkLabels(labels)).length)
90
+ body.labels = labels;
87
91
  // P31a, P36, P11: workflow-level limits and declared input files (absolute paths, pinned at admission).
88
92
  if (usageBudget !== undefined) {
89
93
  const b = usageBudget;
@@ -378,7 +378,7 @@ export function registerMain(pi, ui) {
378
378
  pi.registerTool(defineTool({
379
379
  name: "subagents", label: "Subagents", description: [
380
380
  "Durable asynchronous subagents; run returns {wid} when created (or {submitted:{rid}} while pending). A finished workflow (its notice carries every agent's result) or a question wakes you, so after starting work end your turn: never poll with sleep or repeated status. Crash recovery resumes sessions, not external side effects. Background helper processes (orchestrator, evaluator) exit by themselves about 10 s after all work ends: never kill processes or delete files to 'clean up'. When the user quits pi, this session's running workflows pause (nothing is spent); resume continues them.",
381
- "run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
381
+ "run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs, labels. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
382
382
  "agents: list names, descriptions, default models and source for this cwd; use these names for run.",
383
383
  "send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn; follow-up model:'provider/id' or a pool name runs that generation on it. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model ('provider/id' or a pool name — its first model not used up): a running call switches at its next provider request; an asking, hibernated or queued call launches on it when it runs again; the reply's model/effect (next-request|next-execution|next-generation) says which. status model = model actually used by the last request; switching = requested, not used yet; switchFailed = refused. A provider content refusal (ToS/usage policy) fails the call at once, not retried. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
384
384
  "stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. restart (after an update) replaces the orchestrator with the installed version: refused with busy:<running executions> while any runs. Never force without the user's explicit approval: show the user the refusal's list first, then supply force:'<token>' and reason. Subagents cannot force; hibernated askers and queued calls do not block it. Never kill the orchestrator process. Commands that need the machine (benchmarks, timing) take a lease: tell the subagent to run them as `pi-durable-subagents hold machine [--shared] -- <command>` (FIFO; status lists lease holders and waiters). status: without wid, what runs, asks (with its answer address; hibernated:true holds no slot) or failed, writerWait: a call queued for its git worktree's writer lock (one call whose tools include edit/write runs per worktree; spec writer:false or isolation:'worktree' opts out), sharedWorktree names calls sharing observed edit/write roots (reminder), lease: a call holding or waiting for a resource lease, finished workflows one line each, provider slots held/limit, the config in effect and providers whose usage window is used up (avoided until a probe finds them answering again), and the orchestrator version (versionNote when it differs from the loaded one); wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
package/dist/cli/main.js CHANGED
@@ -132,7 +132,7 @@ export function serviceEntryError(entry) {
132
132
  return `install-service refuses to run from an npx cache (${entry}); the cache can be pruned and the service would break. Install the CLI with \`npm i -g pi-durable-subagents\` and run \`pi-durable-subagents install-service\` again.`;
133
133
  return undefined;
134
134
  }
135
- export const HELP = "pi-durable-subagents: smoke | status [wid] [--json] | events <wid> [--json] | tail [wid] [--json] | start | resume [wid] | drain | stop <wid|callId> | stop-all | run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>] | send --request <id> --to <run-id|wid/key> [--call <key>] --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] [--message <text|@file>] [--model <m>] [--json] [--wait-ms <n>] | stop --request <id> <run-id|wid|wid/key> [--json] [--wait-ms <n>] | describe --key <id> | describe <wid> [--json] | prune [wid] [--older-than <days>] | restart [--force <token> --reason <text>] | hold <resource> [--shared] [--max-wait <s>] [--note <text>] -- <command…> | leases [--json] | doctor [--json] | install-service [--dry-run] | uninstall-service [--dry-run] | chaos [--scenario <1-9>] [--keep] [--json]";
135
+ export const HELP = "pi-durable-subagents: smoke | status [wid] [--json] | events <wid> [--json] | events --all [--since <cursor>] [--limit <n>] [--json] [--wait-ms <n>] | tail [wid] [--json] | start | resume [wid] | drain | stop <wid|callId> | stop-all | run --request <id> --spec <file|-> [--labels <json>] [--cwd <dir>] [--json] [--wait-ms <n>] | send --request <id> --to <run-id|wid/key> [--call <key>] --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] [--message <text|@file>] [--model <m>] [--json] [--wait-ms <n>] | stop --request <id> <run-id|wid|wid/key> [--json] [--wait-ms <n>] | describe --key <id> | describe <wid> [--json] | prune [wid] [--older-than <days>] | restart [--force <token> --reason <text>] | hold <resource> [--shared] [--max-wait <s>] [--note <text>] -- <command…> | leases [--json] | doctor [--json] | install-service [--dry-run] | uninstall-service [--dry-run] | chaos [--scenario <1-9>] [--keep] [--json]";
136
136
  /** Restart: the orchestrator exits when no execution runs (or `force`) and the installed version takes over. */
137
137
  async function restartCommand(home, env, write, options) {
138
138
  const body = { ...(typeof options.force === "string" ? { token: options.force } : options.force === true ? { force: true } : {}), ...(options.reason !== undefined ? { reason: options.reason } : {}), initiator: cliInitiator(env) };
@@ -194,6 +194,11 @@ export async function main(args = process.argv.slice(2), options = {}) {
194
194
  }
195
195
  if (args[0] === "chaos")
196
196
  return (await import("./chaos/index.js")).chaos(args.slice(1), options.env ?? process.env, options.write);
197
+ // R2: the cross-workflow event log (strict flags of its own); `events <wid>` stays below.
198
+ if (args[0] === "events" && args.includes("--all")) {
199
+ const env = options.env ?? process.env;
200
+ return (await import("../events/cli.js")).eventsAll(args.slice(1), { home: dsaHome(env), env, write: options.write ?? ((line) => console.log(line)), starter: options.starter ?? startOrchestrator, waitMs: options.waitMs });
201
+ }
197
202
  // R1–R3: program-facing commands named by request ids (strict flags of their own).
198
203
  if (["run", "send", "describe"].includes(args[0]) || (args[0] === "stop" && args.includes("--request"))) {
199
204
  const env = options.env ?? process.env, requests = await import("./requests.js");
@@ -2,11 +2,11 @@
2
2
  // Exit codes: 0 decided (applied/created), 1 rejected or invalid, 3 request-conflict (the id names other content),
3
3
  // 75 not decided within --wait-ms (retry with the same id and content: safe).
4
4
  import { existsSync, readFileSync } from "node:fs";
5
- import { resolve } from "node:path";
5
+ import { join, resolve } from "node:path";
6
6
  import { setTimeout as delay } from "node:timers/promises";
7
7
  import { readJournalSnapshot } from "../kernel/journal.js";
8
8
  import { reduceLifecycle } from "../kernel/lifecycle.js";
9
- import { journalPath, orchLedger } from "../paths.js";
9
+ import { journalPath, orchLedger, pinnedDir } from "../paths.js";
10
10
  import { findRequest, REQUEST_ID, requestId, requestRid, RequestsBusy, specDigest } from "../requests.js";
11
11
  import { isLive, slotsView, workflowSnapshot } from "../orchestrator/snapshot.js";
12
12
  import { leaseCalls, leaseState } from "../platform/lease.js";
@@ -14,6 +14,10 @@ import { checkAgents, request } from "../agent/main/tool.js";
14
14
  import { discoverAgents } from "../compat/agents.js";
15
15
  import { JT } from "../types.js";
16
16
  import { startOrchestrator, submitIdentified } from "./control.js";
17
+ import { endedExecs, fenceReason } from "../events/fence.js";
18
+ import { parseLabels } from "../events/labels.js";
19
+ import { foldWaits, leaseWaits, waitsOf } from "../events/r7.js";
20
+ import { emptyLedger, foldLedger } from "../orchestrator/ledger.js";
17
21
  export const EXIT = { ok: 0, rejected: 1, conflict: 3, pending: 75 };
18
22
  /** Strict `--name value` / `--flag` parsing; unknown or repeated options are errors. */
19
23
  function flags(args, spec) {
@@ -61,7 +65,7 @@ export async function describe(home, key, now = Date.now()) {
61
65
  const rid = requestRid(key.request), found = await findRequest(home, rid);
62
66
  if (!found)
63
67
  return { state: "absent", request: key.request };
64
- const head = { request: key.request, kind: found.request.kind, spec_digest: specDigest(found.request) };
68
+ const head = { request: key.request, kind: found.request.kind, spec_digest: specDigest(found.request), ...labelsOf(found.request) };
65
69
  const decided = decision(entries, rid), created = createdBy(entries, rid);
66
70
  if (found.request.kind === "run" && created)
67
71
  return { ...await describeWorkflow(home, String(created.wid), entries, now), ...head };
@@ -72,24 +76,57 @@ export async function describe(home, key, now = Date.now()) {
72
76
  return { state: "applied", ...head };
73
77
  return { state: "pending", ...head };
74
78
  }
79
+ /** R6: `{labels}` of a run request that has any. */
80
+ function labelsOf(request) {
81
+ const labels = request?.kind === "run" ? request.body?.labels : undefined;
82
+ return labels && typeof labels === "object" && Object.keys(labels).length ? { labels } : {};
83
+ }
84
+ /** The admitted run request that created `wid` (the ledger keeps it after a prune). */
85
+ function runOf(entries, wid) {
86
+ const created = entries.find(e => e.type === JT.created && e.wid === wid);
87
+ return { created, run: created ? entries.find(e => e.type === "request" && e.request.rid === created.rid)?.request : undefined };
88
+ }
89
+ /** The pinned agents of a workflow revision, read once on demand (R7: the model of a call that names none). */
90
+ function pinnedAgentModel(home, wid, rev) {
91
+ let agents;
92
+ return name => {
93
+ if (!agents) {
94
+ try {
95
+ agents = JSON.parse(readFileSync(join(pinnedDir(home, wid), rev === 1 ? "" : `r${rev}`, "agents.json"), "utf8"));
96
+ }
97
+ catch {
98
+ agents = [];
99
+ }
100
+ }
101
+ return Array.isArray(agents) ? agents.find(a => a?.name === name)?.model : undefined;
102
+ };
103
+ }
75
104
  function describeWorkflow(home, wid, entries, now) {
76
105
  const pruned = entries.find(e => e.type === "pruned" && e.wid === wid);
77
106
  if (pruned)
78
107
  return { state: "pruned", wid, pruned: { ...(pruned.status !== undefined ? { status: String(pruned.status) } : {}), endedAt: Number(pruned.endedAt) },
79
- ...(typeof pruned.request === "string" ? { request: pruned.request } : {}), ...(typeof pruned.spec_digest === "string" ? { spec_digest: pruned.spec_digest } : {}) };
108
+ ...(typeof pruned.request === "string" ? { request: pruned.request } : {}), ...(typeof pruned.spec_digest === "string" ? { spec_digest: pruned.spec_digest } : {}), ...labelsOf(runOf(entries, wid).run) };
80
109
  if (!/^[^/\\\0]+$/.test(wid) || wid === "." || wid === ".." || !existsSync(journalPath(home, wid)))
81
110
  return { state: "absent", wid };
82
- const wf = workflowSnapshot(home, wid), journal = readJournalSnapshot(journalPath(home, wid));
83
- const created = entries.find(e => e.type === JT.created && e.wid === wid), id = created ? requestId(String(created.rid)) : undefined;
84
- const admitted = id ? entries.find(e => e.type === "request" && e.request.rid === created.rid)?.request : undefined;
85
- const slots = slotsView(home, now), leases = leaseCalls(leaseState(home), now);
111
+ // The snapshot and the journal the R7 fold reads must be the same bytes (a writer-wait appended between the two reads
112
+ // would give a reason without its writerWait): read again until the journal did not move around the snapshot.
113
+ let journal = readJournalSnapshot(journalPath(home, wid)), wf = workflowSnapshot(home, wid);
114
+ for (let i = 0, again = readJournalSnapshot(journalPath(home, wid)); again !== journal && i < 5; i++, again = readJournalSnapshot(journalPath(home, wid))) {
115
+ journal = again;
116
+ wf = workflowSnapshot(home, wid);
117
+ }
118
+ const { created, run } = runOf(entries, wid), id = created ? requestId(String(created.rid)) : undefined;
119
+ const admitted = id ? run : undefined;
120
+ const lstate = leaseState(home), slots = slotsView(home, now), leases = leaseCalls(lstate, now);
121
+ // R7: the same fold and decision the orchestrator's collector uses, from the disk snapshots.
122
+ const waits = waitsOf(foldWaits(wid, journal), { now, ledger: foldLedger(emptyLedger(), entries), leases: leaseWaits(lstate, now) }, pinnedAgentModel(home, wid, wf.rev));
86
123
  const line = (lines, model) => { const provider = model?.split("/")[0]; return provider ? lines?.find(l => l.startsWith(`${provider} `)) : undefined; };
87
124
  const latest = [...new Map(wf.calls.map(c => [c.key, c])).values()];
88
125
  const calls = latest.map((c) => {
89
126
  const r = c.result, waiting = r ? undefined : {
90
127
  ...(c.writerWait ? { writerWait: c.writerWait } : {}), ...(leases.get(c.callId) ? { lease: leases.get(c.callId) } : {}),
91
128
  ...(line(slots.slots, c.model) ? { slot: line(slots.slots, c.model) } : {}), ...(line(slots.exhausted, c.model) ? { exhausted: line(slots.exhausted, c.model) } : {}),
92
- ...(c.hibernated ? { hibernated: true } : {})
129
+ ...(c.hibernated ? { hibernated: true } : {}), ...waits.get(c.callId)
93
130
  };
94
131
  return { key: c.key, gen: c.gen, phase: c.phase, agent: c.agent, ...(c.model ? { model: c.model } : {}),
95
132
  ...(r ? { status: r.status, ok: r.ok, ...(r.error ? { error: r.error } : {}), output: r.output, ...(r.data !== undefined ? { data: r.data } : {}) } : {}),
@@ -100,48 +137,20 @@ function describeWorkflow(home, wid, entries, now) {
100
137
  const attention = wf.attention.filter(a => a.kind !== "question").map(a => ({ id: a.id, rev: a.rev, kind: a.kind, ...(a.call ? { call: a.call } : {}), text: a.text }));
101
138
  const state = questions.length ? "asking" : isLive(wf) || wf.status === "parked" ? "running" : "sealed";
102
139
  const fence = lastFence(journal, entries);
103
- return { state, wid, ...(id ? { request: id } : {}), ...(admitted ? { spec_digest: specDigest(admitted) } : {}), status: wf.status, ...(wf.error ? { error: wf.error } : {}),
140
+ return { state, wid, ...(id ? { request: id } : {}), ...(admitted ? { spec_digest: specDigest(admitted) } : {}), ...labelsOf(run), status: wf.status, ...(wf.error ? { error: wf.error } : {}),
104
141
  calls, ...(questions.length ? { questions } : {}), ...(attention.length ? { attention } : {}), ...(fence ? { lastFence: fence } : {}) };
105
142
  }
106
- /** R3, best effort: why the latest fence that interrupted work happened. Every execution ends with a fence; one interrupted
107
- * work only when the execution neither settled (its turn ended) before it nor hibernated (it waits for an answer), and
108
- * was not sealed on purpose: a seal ends an execution on purpose unless its outcome is `unknown` (a `once` call cut off
109
- * in a tool) or the execution was recorded as lost (the loss bound sealed it), which are interruptions themselves.
110
- * A seal for an execution that never ran (a launch failure) or that the call's stop, timeout or budget ended is on
111
- * purpose. restart-force: a forced restart listed the execution as live;
112
- * orchestrator-crash: the execution was launched before an orchestrator start that is not preceded by a clean exit and
113
- * fenced after it (startup recovery); otherwise process-died (the child or its host went away, or a drain fenced it). */
143
+ /** R3, best effort: why the latest fence that interrupted work happened. The per-execution classification and the
144
+ * reason are shared with the event log's `fenced` events (src/events/fence.ts). */
114
145
  export function lastFence(journal, orch) {
115
- const lost = new Set(journal.filter(e => e.type === "loss").map(e => String(e.exec)));
116
- const fencedAt = new Map(journal.filter(e => e.type === JT.fenced).map(e => [String(e.exec), Number(e.seq)]));
117
- const ended = new Set(journal.filter(e => {
118
- const exec = String(e.exec);
119
- // Recovery records `hibernated` after the fence for an execution cut off while only its question's ask ran (P28):
120
- // it was waiting, not working, so that is no interruption either.
121
- if (e.type === "hibernated")
122
- return true;
123
- if (e.type === "settled")
124
- return Number(e.seq) < (fencedAt.get(exec) ?? Infinity);
125
- return e.type === JT.sealed && e.result?.status !== "unknown" && !lost.has(exec);
126
- }).map(e => String(e.exec)));
146
+ const ended = endedExecs(journal);
127
147
  const fence = journal.findLast(e => e.type === JT.fenced && !ended.has(String(e.exec)));
128
- if (!fence)
129
- return undefined;
130
- const exec = String(fence.exec), at = Number(fence.ts);
131
- if (orch.some(e => e.type === "restart" && e.force === true && Array.isArray(e.live) && e.live.includes(exec)))
132
- return { at, exec, reason: "restart-force" };
133
- const launched = journal.find(e => e.type === JT.exec && e.exec === exec), starts = orch.filter(e => e.type === "orchestrator");
134
- const recovery = starts.findLast(s => Number(s.ts) <= at);
135
- if (launched && recovery && Number(launched.ts) < Number(recovery.ts)) {
136
- const prior = orch.filter(e => Number(e.seq) < Number(recovery.seq));
137
- const lastStart = prior.findLast(e => e.type === "orchestrator"), cleanExit = lastStart && prior.some(e => e.type === "orchestrator-exit" && Number(e.seq) > Number(lastStart.seq));
138
- if (!cleanExit)
139
- return { at, exec, reason: "orchestrator-crash" };
140
- }
141
- return { at, exec, reason: "process-died" };
148
+ return fence ? { at: Number(fence.ts), exec: String(fence.exec), reason: fenceReason(journal, orch, fence) } : undefined;
142
149
  }
143
150
  export function renderDescription(d) {
144
151
  const lines = [`${d.request ?? d.wid}: ${d.state}${d.reason ? ` (${d.reason})` : ""}${d.wid && d.request ? ` — ${d.wid}` : ""}${d.status && d.state !== d.status ? ` · ${d.status}` : ""}`];
152
+ if (d.labels)
153
+ lines.push(` labels: ${Object.entries(d.labels).map(([k, v]) => `${k}=${v}`).join(" ")}`);
145
154
  if (d.pruned)
146
155
  lines.push(` pruned: ${d.pruned.status ?? "?"} at ${new Date(d.pruned.endedAt ?? 0).toISOString()}`);
147
156
  if (d.error)
@@ -254,13 +263,14 @@ const forks = (spec) => [spec, ...["tasks", "chain"].flatMap(k => Array.isArray(
254
263
  * form ({agent,task,…} or {tasks|chain:[…],…}); it is validated by the tool's own normalizer and agent check. */
255
264
  export const runCommand = (args, ctx) => refusable(args, ctx, runRequest);
256
265
  async function runRequest(args, ctx, seen) {
257
- const { values, positionals } = flags(args, { request: "value", spec: "value", cwd: "value", json: "flag", "wait-ms": "value" });
266
+ const { values, positionals } = flags(args, { request: "value", spec: "value", cwd: "value", labels: "value", json: "flag", "wait-ms": "value" });
258
267
  const id = text(values, "request"), file = text(values, "spec"), json = values.json === true, wait = waitMs(values, ctx);
259
268
  seen.id = id;
260
269
  seen.json = json;
261
270
  if (!id || !file || positionals.length)
262
- throw new Error("usage: run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]");
271
+ throw new Error("usage: run --request <id> --spec <file|-> [--labels <json>] [--cwd <dir>] [--json] [--wait-ms <n>]");
263
272
  requestRid(id);
273
+ const raw = text(values, "labels"), labels = raw !== undefined ? parseLabels(raw) : undefined;
264
274
  const bytes = file === "-" ? await (ctx.stdin ?? stdin)() : readFileSync(resolve(ctx.cwd ?? process.cwd(), file), "utf8");
265
275
  let spec;
266
276
  try {
@@ -275,11 +285,13 @@ async function runRequest(args, ctx, seen) {
275
285
  throw new Error("--spec describes a run; action must be absent or \"run\"");
276
286
  if (spec.request !== undefined)
277
287
  throw new Error("the request id is --request, not a spec field");
288
+ if (spec.labels !== undefined)
289
+ throw new Error("labels are given with --labels <json>, not in the spec");
278
290
  if (forks(spec))
279
291
  throw new Error("context \"fork\" needs a pi session to fork; it is not available to run --request");
280
292
  // RunBody.cwd = spec.cwd ?? --cwd ?? the current directory, absolute before the digest.
281
293
  const base = resolve(ctx.cwd ?? process.cwd(), text(values, "cwd") ?? "."), dir = typeof spec.cwd === "string" && spec.cwd ? resolve(base, spec.cwd) : base;
282
- const normalized = request({ ...spec, action: "run", ...(typeof spec.cwd === "string" && spec.cwd ? { cwd: dir } : {}) }, dir);
294
+ const normalized = request({ ...spec, action: "run", ...(typeof spec.cwd === "string" && spec.cwd ? { cwd: dir } : {}), ...(labels ? { labels } : {}) }, dir);
283
295
  const body = normalized.body;
284
296
  seen.digest = specDigest({ kind: "run", body });
285
297
  // An id already recorded is decided by that record: the same content gets its first outcome (the agents it named may
@@ -0,0 +1,117 @@
1
+ // R2: `events --all [--since <cursor>] [--limit <n>] [--json] [--wait-ms <n>]` — read the cross-workflow event log.
2
+ // Output is JSON lines (with or without --json). Without --since: `{"head","more":false}`. With --since: the events
3
+ // after the cursor (at most --limit, default and max EVENTS_PAGE_MAX), then `{"head","more"}`: more:true → head is the
4
+ // cursor of the last event printed; more:false → the log head. Exit 0; 4 cursor-expired (other epoch, seq below
5
+ // `dropped`, or beyond the head); 1 malformed cursor or options (`{"error":"invalid-arguments","message"}`) or an
6
+ // unreadable log (`{"error":"log-unreadable","message"}`: corrupt, or gone between reads twice); 75 no log yet within
7
+ // --wait-ms (an orchestrator was started to create it). Read-only: never repairs the log, never starts the orchestrator when the log exists.
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { eventsLog } from "../paths.js";
10
+ import { EPOCH, LogCorrupt, readHead, readPage } from "./log.js";
11
+ import { EVENTS_PAGE_MAX, EXIT_CURSOR_EXPIRED } from "./types.js";
12
+ export const EVENTS_EXIT = { ok: 0, invalid: 1, expired: EXIT_CURSOR_EXPIRED, pending: 75 };
13
+ export const EVENTS_USAGE = "usage: events --all [--since <epoch>:<seq>] [--limit <n>] [--json] [--wait-ms <n>]";
14
+ /** A cursor `<epoch>:<seq>`, or undefined when malformed. */
15
+ export function parseCursor(text) {
16
+ const m = /^([0-9a-f]{16}):(0|[1-9]\d{0,15})$/.exec(text);
17
+ return m && EPOCH.test(m[1]) && Number.isSafeInteger(Number(m[2])) ? { epoch: m[1], seq: Number(m[2]) } : undefined;
18
+ }
19
+ /** Exit 1 with one JSON line `{"error":"invalid-arguments","message"}` (the output stays JSON lines). */
20
+ function invalid(ctx, message) { ctx.write(JSON.stringify({ error: "invalid-arguments", message })); return EVENTS_EXIT.invalid; }
21
+ /** Exit 1 with one JSON line `{"error":"log-unreadable","message"}`. */
22
+ function unreadable(ctx, message) { ctx.write(JSON.stringify({ error: "log-unreadable", message })); return EVENTS_EXIT.invalid; }
23
+ class Vanished extends Error {
24
+ constructor() { super("the event log disappeared while it was read"); }
25
+ }
26
+ class Expired extends Error {
27
+ head;
28
+ constructor(head) { super("cursor-expired"); this.head = head; }
29
+ }
30
+ export async function eventsAll(args, ctx) {
31
+ try {
32
+ return await read(args, ctx);
33
+ }
34
+ catch (error) {
35
+ if (error instanceof Vanished)
36
+ try {
37
+ return await read(args, ctx);
38
+ }
39
+ catch (again) {
40
+ error = again;
41
+ } // retried once
42
+ if (error instanceof LogCorrupt || error instanceof Vanished)
43
+ return unreadable(ctx, error.message);
44
+ throw error;
45
+ }
46
+ }
47
+ async function read(args, ctx) {
48
+ const values = {};
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i], name = arg.startsWith("--") ? arg.slice(2) : undefined;
51
+ const kind = name === "all" || name === "json" ? "flag" : name === "since" || name === "limit" || name === "wait-ms" ? "value" : undefined;
52
+ if (!name || !kind || Object.hasOwn(values, name)) {
53
+ return invalid(ctx, `unknown, repeated or misplaced argument ${JSON.stringify(arg)}; ${EVENTS_USAGE}`);
54
+ }
55
+ if (kind === "flag") {
56
+ values[name] = true;
57
+ continue;
58
+ }
59
+ const value = args[++i];
60
+ if (value === undefined) {
61
+ return invalid(ctx, `--${name} needs a value; ${EVENTS_USAGE}`);
62
+ }
63
+ values[name] = value;
64
+ }
65
+ const number = (name, min, max) => {
66
+ const raw = values[name];
67
+ if (raw === undefined)
68
+ return undefined;
69
+ return typeof raw === "string" && /^\d+$/.test(raw) && Number(raw) >= min && Number(raw) <= max ? Number(raw) : null;
70
+ };
71
+ const limit = number("limit", 1, EVENTS_PAGE_MAX), wait = number("wait-ms", 0, 86_400_000);
72
+ if (limit === null)
73
+ return invalid(ctx, `--limit needs an integer 1-${EVENTS_PAGE_MAX}`);
74
+ if (wait === null)
75
+ return invalid(ctx, "--wait-ms needs a non-negative integer");
76
+ const since = typeof values.since === "string" ? parseCursor(values.since) : undefined;
77
+ if (values.since !== undefined && !since)
78
+ return invalid(ctx, `malformed cursor ${JSON.stringify(values.since)}; a cursor is <epoch>:<seq> as printed in "head" or "cursor"`);
79
+ const path = eventsLog(ctx.home);
80
+ // No log yet: the orchestrator creates it at start (deriving everything still on disk); start one and wait.
81
+ if (!readHead(path)) {
82
+ await ctx.starter(ctx.home, ctx.env);
83
+ const deadline = performance.now() + (wait ?? ctx.waitMs ?? 60_000);
84
+ while (!readHead(path) && performance.now() < deadline)
85
+ await delay(Math.min(100, Math.max(0, deadline - performance.now())));
86
+ if (!readHead(path)) {
87
+ ctx.write(JSON.stringify({ pending: true }));
88
+ return EVENTS_EXIT.pending;
89
+ }
90
+ }
91
+ const cursor = (h, seq) => `${h.epoch}:${seq}`;
92
+ if (!since) {
93
+ const h = readHead(path);
94
+ if (!h)
95
+ throw new Vanished();
96
+ ctx.write(JSON.stringify({ head: cursor(h, h.head), more: false }));
97
+ return EVENTS_EXIT.ok;
98
+ }
99
+ try {
100
+ const page = readPage(path, since.seq, limit ?? EVENTS_PAGE_MAX, h => {
101
+ if (h.epoch !== since.epoch || since.seq < h.dropped || since.seq > h.head)
102
+ throw new Expired(h);
103
+ });
104
+ if (!page)
105
+ throw new Vanished();
106
+ for (const e of page.events)
107
+ ctx.write(JSON.stringify(e));
108
+ ctx.write(JSON.stringify({ head: page.more ? page.events.at(-1).cursor : cursor(page, page.head), more: page.more }));
109
+ return EVENTS_EXIT.ok;
110
+ }
111
+ catch (error) {
112
+ if (!(error instanceof Expired))
113
+ throw error;
114
+ ctx.write(JSON.stringify({ error: "cursor-expired", head: cursor(error.head, error.head.head), oldest: cursor(error.head, error.head.dropped) }));
115
+ return EVENTS_EXIT.expired;
116
+ }
117
+ }