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.
@@ -0,0 +1,128 @@
1
+ // R2: journal → event drafts. Pure functions of durable sources: the entry at journal index i (seq i+1) is derived from
2
+ // that journal's entries up to it and from orchestrator-ledger entries durable before it, so a re-derivation after a
3
+ // crash gives the same events with the same ids (`<wid>:<journal seq>:<type>`, `<wid>:submitted`).
4
+ import { createHash } from "node:crypto";
5
+ import { requestId } from "../requests.js";
6
+ import { JT, isEntry } from "../types.js";
7
+ import { fenceReason, interruptingFence } from "./fence.js";
8
+ import { EVENT_DATA_INLINE_MAX } from "./types.js";
9
+ /** `<wid>@<rev>/<key>@<gen>` → key and gen (a key may itself contain '/' or '@'). */
10
+ export function callParts(call) {
11
+ const m = /^[^@/]+@\d+\/(.*)@(\d+)$/s.exec(call);
12
+ return m ? { key: m[1], gen: Number(m[2]) } : undefined;
13
+ }
14
+ const echo = (id) => ({ ...(id.request !== undefined ? { request: id.request } : {}), ...(id.labels ? { labels: id.labels } : {}) });
15
+ const onCall = (call) => { const p = callParts(call); return p ? { key: p.key, gen: p.gen, call } : { call }; };
16
+ /** The labels of a run body: a non-empty plain object of strings (anything else, and `{}`, is not echoed — as describe
17
+ * and the R7 collector treat them). */
18
+ export function labelsOf(body) {
19
+ const labels = body?.labels;
20
+ if (!labels || typeof labels !== "object" || Array.isArray(labels))
21
+ return undefined;
22
+ const entries = Object.entries(labels);
23
+ return entries.length && entries.every(([, v]) => typeof v === "string") ? Object.fromEntries(entries) : undefined;
24
+ }
25
+ /** `answered.by` from the answer request's sender: a pi session `main:<id>` → `session:<id>` (+ via "ui" when the
26
+ * subagent list sent it, SendBody.by "user"); the CLI sender `cli:<user>@<host>` as is; anything else `unknown`. */
27
+ export function answeredBy(req) {
28
+ const from = req?.from ?? "";
29
+ if (from.startsWith("main:"))
30
+ return { by: `session:${from.slice(5)}`, ...(req.body?.by === "user" ? { via: "ui" } : {}) };
31
+ if (/^cli:[^@]+@.+$/.test(from))
32
+ return { by: from };
33
+ return { by: "unknown" };
34
+ }
35
+ /** `submitted` from the orchestrator ledger's `created {rid, wid}` at index `i` (name from its create-intent). */
36
+ export function deriveCreated(orch, i, id) {
37
+ const e = orch[i], wid = String(e.wid), rid = String(e.rid);
38
+ let name;
39
+ for (let j = i - 1; j >= 0; j--) {
40
+ const c = orch[j];
41
+ if (c.type === "create-intent" && c.rid === rid) {
42
+ name = c.name;
43
+ break;
44
+ }
45
+ }
46
+ return { id: `${wid}:submitted`, ts: e.ts, type: "submitted", wid, ...echo({ ...id, request: requestId(rid) ?? id.request }), ...(typeof name === "string" ? { name } : {}) };
47
+ }
48
+ /** The request the answer to question (call, qid, rev) came with: an `answer-bound` (a hibernated asker) or a `forward`
49
+ * of kind answer (a live one) before index `limit` names its rid; the orchestrator ledger keeps the admitted envelope. */
50
+ function answerOf(entries, limit, orch, call, qid, rev) {
51
+ let rid, forwarded;
52
+ for (let j = limit - 1; j >= 0 && rid === undefined; j--) {
53
+ const e = entries[j];
54
+ if (e.type === "answer-bound" && e.call === call && e.qid === qid && e.rev === rev)
55
+ rid = String(e.rid);
56
+ else if (e.type === "forward" && e.dest === call) {
57
+ const env = e.envelope;
58
+ if (env?.kind === "answer" && env.cond?.qid === qid && env.cond?.rev === rev) {
59
+ rid = String(e.rid);
60
+ forwarded = typeof env.body?.message === "string" ? env.body.message : undefined;
61
+ }
62
+ }
63
+ }
64
+ const request = rid === undefined ? undefined : orch.findLast(e => e.type === "request" && e.request?.rid === rid)?.request;
65
+ const message = request?.body?.message;
66
+ return { request, text: typeof message === "string" ? message : forwarded ?? "" };
67
+ }
68
+ /** The ledger entries durable before journal entry `e` (by time: the ledger and the journal are separate files). A wall
69
+ * clock step back can change which ledger entries a re-derivation sees, and so `by`/`reason` of its event (ids stay). */
70
+ function before(orch, e) {
71
+ let n = orch.length;
72
+ while (n > 0 && Number(orch[n - 1].ts) > e.ts)
73
+ n--;
74
+ return n === orch.length ? orch : orch.slice(0, n);
75
+ }
76
+ /** The events of journal entry `i` of workflow `wid` (usually none). Reads only that journal's entries up to `i` and
77
+ * the ledger entries durable before it, so a re-derivation gives the same events. */
78
+ export function deriveEntry(wid, entries, i, orch, id) {
79
+ const e = entries[i], base = (type) => ({ id: `${wid}:${e.seq}:${type}`, ts: e.ts, wid, ...echo(id) });
80
+ if (isEntry(e, JT.exec)) {
81
+ // `started` once per call (generation): its first execution. A later execution follows a fence; `fenced` when
82
+ // that fence interrupted work (the classification `describe` uses for lastFence).
83
+ let previous;
84
+ for (let j = i - 1; j >= 0 && !previous; j--) {
85
+ const p = entries[j];
86
+ if (p.type === JT.exec && p.call === e.call)
87
+ previous = p;
88
+ }
89
+ if (!previous)
90
+ return [{ ...base("started"), type: "started", ...onCall(e.call), exec: e.exec }];
91
+ const fence = interruptingFence(entries, String(previous.exec), i);
92
+ if (!fence)
93
+ return [];
94
+ return [{ ...base("fenced"), type: "fenced", ...onCall(e.call), exec: String(previous.exec), reason: fenceReason(entries.slice(0, i), before(orch, e), fence), at: fence.ts }];
95
+ }
96
+ if (isEntry(e, JT.attention)) {
97
+ const item = e.item;
98
+ if (item?.kind !== "question" || !item.call || item.qid === undefined)
99
+ return [];
100
+ const parts = callParts(item.call);
101
+ return [{ ...base("asking"), type: "asking", ...onCall(item.call), qid: item.qid, rev: item.rev, question: item.text, to: `${wid}/${parts?.key ?? ""}` }];
102
+ }
103
+ if (isEntry(e, JT.attentionResolved)) {
104
+ if (e.resolution !== "answered")
105
+ return [];
106
+ let item;
107
+ for (let j = i - 1; j >= 0 && !item; j--) {
108
+ const a = entries[j];
109
+ if (isEntry(a, JT.attention) && a.item.id === e.id && a.item.rev === e.rev)
110
+ item = a;
111
+ }
112
+ const q = item?.item;
113
+ if (!q || q.kind !== "question" || !q.call || q.qid === undefined)
114
+ return [];
115
+ const answer = answerOf(entries, i, before(orch, e), q.call, q.qid, q.rev);
116
+ return [{ ...base("answered"), type: "answered", ...onCall(q.call), qid: q.qid, rev: q.rev, ...answeredBy(answer.request),
117
+ digest: createHash("sha256").update(answer.text, "utf8").digest("hex"), length: answer.text.length }];
118
+ }
119
+ if (isEntry(e, JT.sealed)) {
120
+ const r = (e.result ?? {}), data = Object.hasOwn(r, "data") && r.data !== undefined ? JSON.stringify(r.data) : undefined;
121
+ const bytes = data === undefined ? 0 : Buffer.byteLength(data);
122
+ return [{ ...base("sealed"), type: "sealed", ...onCall(e.call), status: String(r.status ?? "unknown"), ...(r.error ? { error: String(r.error) } : {}),
123
+ ...(data === undefined ? {} : bytes <= EVENT_DATA_INLINE_MAX ? { data: r.data } : { data_omitted: bytes }) }];
124
+ }
125
+ if (e.type === JT.done)
126
+ return [{ ...base("workflow-done"), type: "workflow-done", status: String(e.status), ...(e.error !== undefined ? { error: String(e.error) } : {}) }];
127
+ return [];
128
+ }
@@ -0,0 +1,61 @@
1
+ // R2/R3: which fences interrupted work, shared by `describe` (lastFence) and the event deriver (`fenced`). Read-only over
2
+ // committed journal entries; `limit` restricts the view to the first `limit` entries (the deriver may read only entries
3
+ // before the one it derives, so a re-derivation after a crash sees the same history).
4
+ import { JT } from "../types.js";
5
+ /** R3: Executions whose fence did NOT interrupt work: every execution ends with a fence; one interrupted work only when
6
+ * the execution neither settled (its turn ended) before it nor hibernated (it waits for an answer), and was not sealed
7
+ * on purpose: a seal ends an execution on purpose unless its outcome is `unknown` (a `once` call cut off in a tool) or
8
+ * the execution was recorded as lost (the loss bound sealed it), which are interruptions themselves. A seal for an
9
+ * execution that never ran (a launch failure) or that the call's stop, timeout or budget ended is on purpose. */
10
+ export function endedExecs(journal, limit = journal.length) {
11
+ const lost = new Set(), fencedAt = new Map(), ended = new Set();
12
+ for (let i = 0; i < limit; i++) {
13
+ const e = journal[i];
14
+ if (e.type === "loss")
15
+ lost.add(String(e.exec));
16
+ else if (e.type === JT.fenced)
17
+ fencedAt.set(String(e.exec), e.seq);
18
+ }
19
+ for (let i = 0; i < limit; i++) {
20
+ const e = journal[i], exec = String(e.exec);
21
+ // Recovery records `hibernated` after the fence for an execution cut off while only its question's ask ran (P28):
22
+ // it was waiting, not working, so that is no interruption either.
23
+ if (e.type === "hibernated")
24
+ ended.add(exec);
25
+ else if (e.type === "settled") {
26
+ if (e.seq < (fencedAt.get(exec) ?? Infinity))
27
+ ended.add(exec);
28
+ }
29
+ else if (e.type === JT.sealed && e.result?.status !== "unknown" && !lost.has(exec))
30
+ ended.add(exec);
31
+ }
32
+ return ended;
33
+ }
34
+ /** R3, best effort: why `fence` (a `fenced` entry that interrupted work) happened. restart-force: a forced restart listed
35
+ * the execution as live; orchestrator-crash: the execution was launched before an orchestrator start that is not
36
+ * preceded by a clean exit and fenced after it (startup recovery); otherwise process-died (the child or its host went
37
+ * away, or a drain fenced it). */
38
+ export function fenceReason(journal, orch, fence) {
39
+ const exec = String(fence.exec), at = Number(fence.ts);
40
+ if (orch.some(e => e.type === "restart" && e.force === true && Array.isArray(e.live) && e.live.includes(exec)))
41
+ return "restart-force";
42
+ const launched = journal.find(e => e.type === JT.exec && e.exec === exec), starts = orch.filter(e => e.type === "orchestrator");
43
+ const recovery = starts.findLast(s => Number(s.ts) <= at);
44
+ if (launched && recovery && Number(launched.ts) < Number(recovery.ts)) {
45
+ const prior = orch.filter(e => Number(e.seq) < Number(recovery.seq));
46
+ const lastStart = prior.findLast(e => e.type === "orchestrator"), cleanExit = lastStart && prior.some(e => e.type === "orchestrator-exit" && Number(e.seq) > Number(lastStart.seq));
47
+ if (!cleanExit)
48
+ return "orchestrator-crash";
49
+ }
50
+ return "process-died";
51
+ }
52
+ /** R2: the fence of `exec` among the first `limit` entries when it interrupted work, else undefined. */
53
+ export function interruptingFence(journal, exec, limit = journal.length) {
54
+ let fence;
55
+ for (let i = limit - 1; i >= 0 && !fence; i--) {
56
+ const e = journal[i];
57
+ if (e.type === JT.fenced && e.exec === exec)
58
+ fence = e;
59
+ }
60
+ return fence && !endedExecs(journal, limit).has(exec) ? fence : undefined;
61
+ }
@@ -0,0 +1,53 @@
1
+ // R6 (owed requirements §20.2): caller labels of a run — `run --labels <json>`, the tool's `labels`, and the
2
+ // orchestrator's admission all validate with this one function. Labels are part of RunBody, so they are part of the
3
+ // request's spec_digest (the same request id with other labels is a request-conflict) and are echoed by `describe` and
4
+ // on every event of the workflow.
5
+ /** At most this many keys. */
6
+ export const LABELS_MAX_KEYS = 32;
7
+ /** A key: 1-64 characters of [A-Za-z0-9_.:-]. */
8
+ export const LABEL_KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
9
+ /** A value: a string of at most this many UTF-16 units. */
10
+ export const LABEL_VALUE_MAX = 256;
11
+ /** The labels' JSON is at most this many UTF-8 bytes. */
12
+ export const LABELS_JSON_MAX = 4096;
13
+ /** What is wrong with `value` as run labels (naming the offending key), or undefined when they are valid. */
14
+ export function labelsProblem(value) {
15
+ if (!value || typeof value !== "object" || Array.isArray(value))
16
+ return "labels must be a JSON object of string values";
17
+ const proto = Object.getPrototypeOf(value);
18
+ if (proto !== Object.prototype && proto !== null)
19
+ return "labels must be a plain JSON object of string values";
20
+ const entries = Object.entries(value);
21
+ if (entries.length > LABELS_MAX_KEYS)
22
+ return `labels have ${entries.length} keys; at most ${LABELS_MAX_KEYS}`;
23
+ for (const [key, v] of entries) {
24
+ if (!LABEL_KEY.test(key))
25
+ return `label key ${JSON.stringify(key)} must be 1-64 characters [A-Za-z0-9_.:-]`;
26
+ if (typeof v !== "string")
27
+ return `label ${JSON.stringify(key)} must have a string value`;
28
+ if (v.length > LABEL_VALUE_MAX)
29
+ return `label ${JSON.stringify(key)} is ${v.length} characters long; at most ${LABEL_VALUE_MAX}`;
30
+ }
31
+ const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
32
+ if (bytes > LABELS_JSON_MAX)
33
+ return `labels are ${bytes} bytes of JSON; at most ${LABELS_JSON_MAX}`;
34
+ return undefined;
35
+ }
36
+ /** Valid labels as given (throws the problem otherwise). */
37
+ export function checkLabels(value) {
38
+ const problem = labelsProblem(value);
39
+ if (problem)
40
+ throw new Error(problem);
41
+ return value;
42
+ }
43
+ /** `--labels <json>`: parse and validate. */
44
+ export function parseLabels(json) {
45
+ let value;
46
+ try {
47
+ value = JSON.parse(json);
48
+ }
49
+ catch (error) {
50
+ throw new Error(`--labels is not JSON: ${error.message}`);
51
+ }
52
+ return checkLabels(value);
53
+ }