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 +30 -0
- package/README.md +120 -4
- package/dist/agent/child.js +4 -4
- package/dist/agent/main/schema.js +2 -1
- package/dist/agent/main/tool.js +5 -1
- package/dist/agent/main.js +1 -1
- package/dist/cli/main.js +6 -1
- package/dist/cli/requests.js +59 -47
- package/dist/events/cli.js +117 -0
- package/dist/events/derive.js +128 -0
- package/dist/events/fence.js +61 -0
- package/dist/events/labels.js +53 -0
- package/dist/events/log.js +491 -0
- package/dist/events/pump.js +351 -0
- package/dist/events/r7.js +412 -0
- package/dist/events/types.js +19 -0
- package/dist/kernel/journal.js +23 -2
- package/dist/orchestrator/config.js +3 -2
- package/dist/orchestrator/engine.js +77 -9
- package/dist/orchestrator/executor/hibernate.js +8 -13
- package/dist/orchestrator/executor/session.js +28 -2
- package/dist/orchestrator/snapshot.js +11 -3
- package/dist/orchestrator/store.js +3 -0
- package/dist/paths.js +2 -0
- package/dist/types.js +3 -0
- package/package.json +1 -1
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
|
|
314
|
-
|
|
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.
|
|
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,
|
package/dist/agent/child.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 });
|
package/dist/agent/main/tool.js
CHANGED
|
@@ -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;
|
package/dist/agent/main.js
CHANGED
|
@@ -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");
|
package/dist/cli/requests.js
CHANGED
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
107
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|