pi-durable-subagents 1.0.18 → 1.0.21

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.
@@ -7,8 +7,10 @@
7
7
  // its seal has a finished attention independent of workflow completion.
8
8
  // resumed {rid,n} supersedes a terminal park. emit {pos,value} records script outputs.
9
9
  // stop-requested {rid,call?} marks a call or workflow stop as taking effect, so its replay is applied, not already-sealed.
10
- // Orch entry pruned {rid,wid,endedAt,bytes} is the decisive record of a prune: appended before the journal handle is
11
- // closed and w/<wid> and its staging dirs are removed (bytes = footprint measured just before). Nothing is rewritten (A1).
10
+ // Orch entry pruned {rid,wid,endedAt,bytes,status,request?,spec_digest?} is the decisive record of a prune: appended before
11
+ // the journal handle is closed and w/<wid> and its staging dirs are removed (bytes = footprint measured just before;
12
+ // status = the final workflow status; request/spec_digest when the workflow was created by a request id, R1 tombstone).
13
+ // Nothing is rewritten (A1): the admitted request and created entries stay, so a retried id still resolves to this wid.
12
14
  import { watch } from 'node:fs';
13
15
  import { mkdir, readdir, unlink } from 'node:fs/promises';
14
16
  import { resolve, join } from 'node:path';
@@ -17,7 +19,9 @@ import { contentHash } from "../kernel/ids.js";
17
19
  import { planDecisions, reduceLifecycle } from "../kernel/lifecycle.js";
18
20
  import { scanInbox } from "../kernel/mailbox.js";
19
21
  import { orchInbox, pinnedDir } from "../paths.js";
22
+ import { requestId, specDigest } from "../requests.js";
20
23
  import { JT, attentionEntries, isEntry } from "../types.js";
24
+ import { isForceRestart, restartRefusal } from "./restart.js";
21
25
  import { EvaluatorClient } from "./evaluator-client.js";
22
26
  import { Store, revisionEntries, terminalEntry } from "./store.js";
23
27
  import { formatUsage, holdOf, refusedResult, snapshotFromEntries } from "./snapshot.js";
@@ -32,6 +36,10 @@ const wakeStatus = (status) => {
32
36
  return 'unknown';
33
37
  return status;
34
38
  };
39
+ /** A follow-up still going when its workflow ended has no result yet: say so instead of calling it unknown. */
40
+ /** A follow-up generation still going when its workflow ended is named by its phase; anything else without a result is unknown. */
41
+ const going = (c) => !c.result && c.phase !== 'sealed' && c.gen > 1;
42
+ const callStatus = (c) => going(c) ? c.phase : wakeStatus(c.result?.status);
35
43
  /** v12 §6: Name available agents in the refusal instead of making the caller guess. */
36
44
  export function unknownAgent(name, agents) {
37
45
  return `unknown agent ${JSON.stringify(name)}; available agents: ${agents.map(a => a.name).sort().join(", ") || "none"}`;
@@ -43,11 +51,11 @@ export function finishedText(wid, entries, call) {
43
51
  const calls = call ? snap.calls.filter(c => c.callId === call) : [...latest.values()];
44
52
  const counts = new Map();
45
53
  for (const c of calls) {
46
- const status = wakeStatus(c.result?.status);
54
+ const status = callStatus(c);
47
55
  counts.set(status, (counts.get(status) ?? 0) + 1);
48
56
  }
49
57
  const parts = [...counts].map(([status, count]) => `${count} ${status}`);
50
- const heading = call ? `${label}/${calls[0]?.key ?? call}@${calls[0]?.gen ?? '?'} (follow-up) ${wakeStatus(calls[0]?.result?.status)}:` :
58
+ const heading = call ? `${label}/${calls[0]?.key ?? call}@${calls[0]?.gen ?? '?'} (follow-up) ${calls[0] ? callStatus(calls[0]) : 'unknown'}:` :
51
59
  `${label} (${wid}) ${snap.status}${parts.length ? `: ${parts.join('; ')}` : ''}`;
52
60
  const footer = `${snap.error ? `\nError: ${tail(snap.error, 500)}` : ''}${charged(snap.usage) ? `\nUsage: ${charged(snap.usage)}` : ''}\nFull output: subagents status wid:${wid}`;
53
61
  const prefix = tail(heading, Math.max(1, 6000 - footer.length - 1));
@@ -70,7 +78,8 @@ export function finishedText(wid, entries, call) {
70
78
  continue;
71
79
  const result = c.result;
72
80
  // A stop leaves the agent's edits where they are; say so, so nobody mistakes a stopped agent for a clean undo.
73
- const name = `${c.key}${c.gen > 1 ? `@${c.gen}` : ''}: ${wakeStatus(result?.status)}${result?.status === 'stopped' ? ' (edits it made so far are left in place)' : ''}`;
81
+ const still = going(c) ? ' (a follow-up still going; you are told when it ends)' : '';
82
+ const name = `${c.key}${c.gen > 1 ? `@${c.gen}` : ''}: ${callStatus(c)}${still}${result?.status === 'stopped' ? ' (edits it made so far are left in place)' : ''}`;
74
83
  const title = `\n${tail(name, allowance - 1)}`;
75
84
  const error = result?.error && allowance - title.length > 10 ? `\n Error: ${tail(result.error, Math.min(300, allowance - title.length - 9))}` : '';
76
85
  const space = allowance - title.length - error.length;
@@ -446,13 +455,23 @@ export class Engine {
446
455
  else if (req.kind === 'restart') {
447
456
  // A replay (the restart was recorded, then the process ended before its resolution) applies without restarting again.
448
457
  if (!this.ledgers.orch.entries().some(e => e.type === 'restart' && e.rid === req.rid)) {
449
- const force = req.body?.force === true;
458
+ // Only a restart that fenced something is recorded as a force (a token with nothing running fences nothing).
459
+ const body = req.body ?? {}, force = isForceRestart(body);
460
+ // A claimed subagent call only restricts (it cannot force); otherwise the main session's sender is authoritative.
461
+ const initiator = body.initiator && 'call' in body.initiator ? body.initiator : req.from.startsWith('main:') ? { origin: req.from } : body.initiator ?? { origin: req.from };
450
462
  const gate = this.executor.quiesce?.() ?? { live: [], resume() { } };
451
- if (gate.live.length && !force) {
463
+ const reason = restartRefusal(this.ledgers.home, gate.live.map(l => ({ ...l, origin: this.store.workflows.get(l.wid)?.origin })), body, req.from.startsWith('main:'));
464
+ if (reason) {
452
465
  gate.resume();
453
- return { action: 'reject', reason: this.busyReason(gate.live) };
466
+ return { action: 'reject', reason };
467
+ }
468
+ try {
469
+ await this.ledgers.orch.append('restart', { rid: req.rid, force: force && gate.live.length > 0, reason: body.reason, initiator, from: req.from, live: gate.live.map(l => l.exec) });
470
+ }
471
+ catch (error) {
472
+ gate.resume();
473
+ throw error;
454
474
  }
455
- await this.ledgers.orch.append('restart', { rid: req.rid, force, live: gate.live.map(l => l.exec) });
456
475
  this.restarting = true;
457
476
  }
458
477
  }
@@ -462,15 +481,6 @@ export class Engine {
462
481
  return { action: 'reject', reason: 'unsupported-kind' };
463
482
  return { action: 'apply' };
464
483
  }
465
- /** Restart refused: which executions run now, whose they are and for how long, and the way to proceed. */
466
- busyReason(live) {
467
- const now = Date.now(), minutes = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
468
- const shown = live.slice(0, 8).map(l => {
469
- const origin = this.store.workflows.get(l.wid)?.origin;
470
- return `${l.wid}/${l.key}${l.phase === 'gate' ? ' gate' : ''} ${minutes(now - l.since)}${origin ? ` from ${origin}` : ''}`;
471
- });
472
- return `busy: ${live.length} running execution${live.length === 1 ? '' : 's'}: ${shown.join('; ')}${live.length > shown.length ? `; +${live.length - shown.length} more` : ''} — retry when they finish (or drain first), or force to fence them; they resume on the new orchestrator`;
473
- }
474
484
  /** Set by an applied restart: the loop ends and the process exits; its successor recovers every workflow. */
475
485
  get restartRequested() { return this.restarting; }
476
486
  /** A1, housekeeping: Prune finished workflows (named, or all ended more than olderThanDays ago); a replay of a
@@ -518,7 +528,11 @@ export class Engine {
518
528
  /** A1, housekeeping: The pruned entry commits first; then the handle closes and the files go (recovery finishes them). */
519
529
  async pruneWorkflow(rid, wf) {
520
530
  const bytes = await this.store.footprint(wf.wid);
521
- await this.ledgers.orch.append('pruned', { rid, wid: wf.wid, endedAt: this.terminal(wf).ts, bytes });
531
+ const done = this.terminal(wf), entries = this.ledgers.orch.entries();
532
+ const createdBy = String(entries.find(e => e.type === JT.created && e.wid === wf.wid)?.rid ?? '');
533
+ const admitted = requestId(createdBy) !== undefined ? entries.find(e => e.type === 'request' && e.request.rid === createdBy)?.request : undefined;
534
+ const identity = admitted ? { request: requestId(createdBy), spec_digest: specDigest(admitted) } : {};
535
+ await this.ledgers.orch.append('pruned', { rid, wid: wf.wid, endedAt: done.ts, bytes, status: String(done.status), ...identity });
522
536
  this.states.delete(wf.wid);
523
537
  await this.store.drop(wf.wid);
524
538
  try {
@@ -557,7 +571,7 @@ export class Engine {
557
571
  throw new Error(`Unknown pinned agent: ${spec.agent}`);
558
572
  return { wid: st.wf.wid, widRev: `${st.wf.wid}@${st.wf.revision}`, key: entry.key, gen: entry.gen,
559
573
  callId: `${st.wf.wid}@${st.wf.revision}/${entry.key}@${entry.gen}`, spec, agent, workflowBudget: st.wf.pins.usageBudget, cwd: resolve(st.wf.cwd, spec.cwd ?? '.'), journal: st.wf.journal,
560
- ...(st.wf.pins.origin !== undefined ? { originSession: join(pinnedDir(this.ledgers.home, st.wf.wid), ...(st.wf.revision === 1 ? [] : [`r${st.wf.revision}`]), 'origin.jsonl') } : {}),
574
+ ...(st.wf.originPath !== undefined ? { originSession: st.wf.originPath } : {}),
561
575
  ...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {}),
562
576
  ...(entry.type === 'generation' && typeof entry.model === 'string' ? { model: entry.model } : {}) };
563
577
  }
@@ -879,6 +879,7 @@ export default function createExecutor(ledgers, options = {}) {
879
879
  if (exec && sleeping?.exec === exec) {
880
880
  await fence(journal, exec, { park: a });
881
881
  await release(exec);
882
+ await adoptAnswer(journal, t.callId, sleeping);
882
883
  for (;;) {
883
884
  if (interrupted(a) || has(journal, "timeout-intent", exec) || t.spec.timeoutMs !== undefined && activeTotal(journal.entries(), t.callId) >= t.spec.timeoutMs)
884
885
  break;
@@ -908,6 +909,16 @@ export default function createExecutor(ledgers, options = {}) {
908
909
  if (ev.budget || reached(totalUsage(journal.entries(), t.callId), t.spec.budget))
909
910
  return finish(journal, t.callId, exec, makeResult("budget"));
910
911
  if (!bound) {
912
+ // P28: an execution cut off while its only unfinished tool call is the open question's `ask` (before it
913
+ // hibernated) was waiting, not working: it hibernates now and resumes with the answer, like a planned
914
+ // hibernation; it is neither a loss nor, for `once`, an unknown outcome.
915
+ const asking = openQuestion(entries);
916
+ const segment = entries.findLastIndex(e => e.type === "custom" && e.customType === CT.exec && e.data?.exec === exec);
917
+ const ownQuestion = !!asking && segment >= 0 && entries.slice(segment + 1).some(e => e.customType === CT.question && e.data?.qid === asking.qid);
918
+ if (asking && ownQuestion && dangling.length && dangling.every(d => d.startsWith("ask (")) && hibernation(journal, t.callId)?.exec !== exec) {
919
+ await serial(() => journal.append("hibernated", { call: t.callId, exec, qid: asking.qid, rev: asking.rev }));
920
+ continue;
921
+ }
911
922
  if (ev.report)
912
923
  return finish(journal, t.callId, exec, buildCallResult({ key: t.key, gen: t.gen, status: ev.report.outcome, output: ev.text, usage: ev.usage,
913
924
  ...(Object.hasOwn(ev.report, "data") ? { report: { data: ev.report.data } } : {}), ...(Array.isArray(ev.report.artifacts) ? { artifacts: ev.report.artifacts } : {}) }));
@@ -1038,6 +1049,37 @@ export default function createExecutor(ledgers, options = {}) {
1038
1049
  }
1039
1050
  }
1040
1051
  }
1052
+ /**
1053
+ * P28: an answer forwarded to the child while the question was still open but before the call hibernated (a restart
1054
+ * or crash cut the asker off, or the planned hibernation fenced it first) never reached a live child. Once the call
1055
+ * hibernates it is bound to the question like an answer given during hibernation; the forward is retired, so the
1056
+ * reply "apply" already given (and any retry with the same rid) stays true.
1057
+ */
1058
+ async function adoptAnswer(journal, call, sleeping) {
1059
+ const entries = await readCall(journal, call);
1060
+ await serial(async () => {
1061
+ const all = journal.entries();
1062
+ if (all.some(e => e.type === "answer-bound" && e.call === call && e.qid === sleeping.qid && e.rev === sleeping.rev))
1063
+ return;
1064
+ const receipts = new Set(entries.map(receiptId).filter(rid => rid !== undefined));
1065
+ const f = all.find(e => {
1066
+ if (e.type !== "forward" || e.dest !== call)
1067
+ return false;
1068
+ const env = e.envelope;
1069
+ return env.kind === "answer" && env.cond?.qid === sleeping.qid && env.cond?.rev === sleeping.rev && !receipts.has(String(e.rid2)) &&
1070
+ !all.some(r => r.rid2 === e.rid2 && (r.type === "forward-retired" || r.type === "forward-delivered"));
1071
+ });
1072
+ if (!f)
1073
+ return;
1074
+ const item = attentionEntries(all).find(e => e.item.call === call && e.item.qid === sleeping.qid && e.item.rev === sleeping.rev)?.item;
1075
+ const message = String(f.envelope.body?.message ?? "");
1076
+ // A new identity: the stale answer may still sit in the child's inbox under the forward's rid2.
1077
+ await journal.append("answer-bound", { call, qid: sleeping.qid, rev: sleeping.rev, rid: f.rid, rid2: contentHash([String(f.rid2), "hibernated"]), hash: f.hash,
1078
+ message: `${HIBERNATED_NOTE}\n\nQuestion: ${item?.text ?? sleeping.qid}\nAnswer: ${message}` });
1079
+ await journal.append("forward-retired", { rid: f.rid, rid2: f.rid2, reason: "bound-to-hibernation" });
1080
+ await (await outbox).markResolved(String(f.rid2));
1081
+ });
1082
+ }
1041
1083
  async function replayForward(e) {
1042
1084
  const envelope = e.envelope;
1043
1085
  await (await outbox).send(envelope.to, envelope.kind, envelope.body, envelope.cond, { rid: String(e.rid2) });
@@ -6,6 +6,8 @@
6
6
  // config{hash,config}: the orchestrator settings in effect from here; config-rejected{error,hash?}: a change of
7
7
  // config.json refused, while the earlier settings stay (cleared by the next config record).
8
8
  // orchestrator{version,pid} / orchestrator-exit{pid}: the orchestrator running (its package version) and its exit.
9
+ // restart{rid,force,reason?,initiator?,from?,live}: accepted restart; the next start consumes its status provenance.
10
+ // Old records lack reason/initiator/from and remain readable (A1).
9
11
  // writer-hold/writer-release{root,call}: the writer lock of a worktree root, held by one call until it ends.
10
12
  import { foldExhaustion } from "./providers.js";
11
13
  import { isEntry } from "../types.js";
@@ -31,8 +33,12 @@ export function applyLedger(state, e) {
31
33
  }
32
34
  else if (isEntry(e, "config-rejected"))
33
35
  state.rejected = { error: String(e.error), ts: e.ts };
34
- else if (isEntry(e, "orchestrator"))
35
- state.orchestrator = { version: String(e.version), pid: Number(e.pid), ...(e.start ? { start: String(e.start) } : {}), ts: e.ts, ...(e.restart === true ? { restart: true } : {}) };
36
+ else if (e.type === "restart")
37
+ state.pendingRestart = e.force === true ? { ts: e.ts, reason: e.reason, initiator: e.initiator, from: e.from } : undefined;
38
+ else if (isEntry(e, "orchestrator")) {
39
+ state.orchestrator = { version: String(e.version), pid: Number(e.pid), ...(e.start ? { start: String(e.start) } : {}), ts: e.ts, ...(e.restart === true ? { restart: true } : {}), ...(state.pendingRestart ? { forceRestart: state.pendingRestart } : {}) };
40
+ delete state.pendingRestart;
41
+ }
36
42
  else if (e.type === "writer-hold") {
37
43
  if (!state.writers.has(String(e.root)))
38
44
  state.writers.set(String(e.root), { call: String(e.call), ts: e.ts });
@@ -52,7 +58,7 @@ export function applyLedger(state, e) {
52
58
  export function foldLedger(state, entries) {
53
59
  // Journal readers keep the entry objects of a ledger as it grows (kernel/journal.ts), so identity tells them apart.
54
60
  if (state.seen && entries[state.seen - 1] !== state.last)
55
- Object.assign(state, emptyLedger(), { config: undefined, rejected: undefined, orchestrator: undefined, last: undefined });
61
+ Object.assign(state, emptyLedger(), { config: undefined, rejected: undefined, orchestrator: undefined, pendingRestart: undefined, last: undefined });
56
62
  for (; state.seen < entries.length; state.seen++)
57
63
  applyLedger(state, entries[state.seen]);
58
64
  state.last = entries[state.seen - 1];
@@ -0,0 +1,57 @@
1
+ // Restart guards shared by the orchestrator and the legacy client. The caller owns the launch gate;
2
+ // these checks never change durable state. A1: only an accepted restart is appended to the ledger.
3
+ import { contentHash } from "../kernel/ids.js";
4
+ import { leaseCalls, leaseState } from "../platform/lease.js";
5
+ export const subagentRestartError = "a subagent cannot force a restart: it would fence itself and other sessions' work; ask the user";
6
+ export const restartToken = (live) => contentHash(live.map(l => l.exec).sort()).slice(0, 12);
7
+ /** Old senders' boolean force is read only to refuse it when executions are live. */
8
+ export const isForceRestart = (body) => body.token !== undefined || body.force === true;
9
+ export function restartInputError(body, subagent = false) {
10
+ const force = isForceRestart(body);
11
+ if (force && (subagent || body.initiator && "call" in body.initiator))
12
+ return subagentRestartError;
13
+ // An old client's bare `force: true` carries no reason: it is never a force (no token) and is refused with the
14
+ // list of running executions, so it is not rejected for the missing reason first.
15
+ if ((body.token !== undefined || body.reason !== undefined) && (typeof body.reason !== "string" || !body.reason.trim() || body.reason.length > 500))
16
+ return "restart reason must be non-empty and at most 500 characters (force requires --reason)";
17
+ if (body.token !== undefined && (typeof body.token !== "string" || !/^[a-f0-9]{12}$/.test(body.token)))
18
+ return "restart force needs the 12-hex token from a refused restart; first show the user the running executions";
19
+ return undefined;
20
+ }
21
+ export function restartRefusal(home, live, body, tool = false, now = Date.now()) {
22
+ const invalid = restartInputError(body);
23
+ if (invalid)
24
+ return invalid;
25
+ if (!live.length)
26
+ return undefined;
27
+ const token = restartToken(live);
28
+ if (body.token === token)
29
+ return undefined;
30
+ const leases = leaseCalls(leaseState(home).map(r => ({ ...r, waiters: [] })), now);
31
+ const groups = new Map();
32
+ for (const l of live) {
33
+ const origin = l.origin ?? "unknown";
34
+ groups.set(origin, [...(groups.get(origin) ?? []), l]);
35
+ }
36
+ const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
37
+ return [
38
+ ...(body.token ? [`the running executions changed since ${body.token}`] : isForceRestart(body) ? ["force without a token is refused; first show the user the running executions"] : []),
39
+ `busy: ${live.length} running execution${live.length === 1 ? "" : "s"} — fencing them interrupts these sessions:`,
40
+ ...[...groups].sort(([a], [b]) => a.localeCompare(b)).flatMap(([origin, executions]) => [
41
+ `${origin}:`, ...executions.map(l => ` ${l.wid}/${l.key} ${age(now - l.since)}${l.phase === "gate" ? " gate" : ""}${leases.has(l.callId) ? ` ${leases.get(l.callId)}` : ""}`),
42
+ ]),
43
+ `token: ${token}`,
44
+ tool ? `to fence exactly these: subagents {action:"restart", force:"${token}", reason:"<why>"}` : `to fence exactly these: pi-durable-subagents restart --force ${token} --reason "<why>"`,
45
+ ].join("\n");
46
+ }
47
+ /** Status is one line even if the reason or parent command contained newlines. */
48
+ export const restartLine = (s) => s.replace(/\s+/g, " ").trim();
49
+ export function initiatorSummary(initiator, from) {
50
+ if (initiator && "call" in initiator)
51
+ return restartLine(initiator.call);
52
+ if (initiator && "origin" in initiator)
53
+ return restartLine(initiator.origin);
54
+ if (initiator && "cli" in initiator)
55
+ return restartLine(`cli:${initiator.cli.user}@${initiator.cli.host}`);
56
+ return restartLine(from ?? "unknown");
57
+ }
@@ -1,5 +1,6 @@
1
1
  // Read-only status snapshots (P25: `status` is always a fresh snapshot). Pure readers of committed journals:
2
2
  // never depend on orchestrator memory, so the UI, the CLI and the main agent see the same durable state.
3
+ import { initiatorSummary, restartLine } from "./restart.js";
3
4
  import { existsSync, readFileSync, readdirSync } from "node:fs";
4
5
  import { join } from "node:path";
5
6
  import { compileFanout } from "../compat/fanout.js";
@@ -492,11 +493,13 @@ const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()]
492
493
  const ledgerStates = new Map();
493
494
  /** The orchestrator running now (its last `orchestrator` record, without an exit, whose process lives), and a note when
494
495
  * its version is not the one this process loaded: running work stays on the version it started with. */
495
- export function orchestratorView(state, loaded = packageVersion()) {
496
+ export function orchestratorView(state, loaded = packageVersion(), now = Date.now()) {
496
497
  const o = state.orchestrator;
497
498
  if (!o || o.exited || !processAlive(o.pid, o.start))
498
499
  return {};
499
- return { orchestrator: `${o.version} (pid ${o.pid})`, ...(o.version === loaded ? {} : { versionNote: versionNote(o.version, loaded) }) };
500
+ const r = o.forceRestart, note = r && now - r.ts < 24 * 60 * 60_000
501
+ ? `; restarted by force ${age(now - r.ts)} ago by ${initiatorSummary(r.initiator, r.from)}: ${restartLine(r.reason ?? "no reason recorded")}` : "";
502
+ return { orchestrator: `${o.version} (pid ${o.pid})${note}`, ...(o.version === loaded ? {} : { versionNote: versionNote(o.version, loaded) }) };
500
503
  }
501
504
  /** Whether the recorded orchestrator still runs. On Linux its start time also tells it from a later process given the
502
505
  * same pid after a crash (elsewhere the pid alone is checked). */
@@ -532,7 +535,7 @@ export function versionNote(running, loaded) {
532
535
  : `the orchestrator runs durable-subagents ${running}, this pi loaded ${loaded}: running work stays on ${running}. ` +
533
536
  `It exits about 10 s after all work ends and starts again on the installed version. To switch sooner: ` +
534
537
  `restart (\`pi-durable-subagents restart\` or the subagents tool's restart action) — refused while an execution runs, ` +
535
- `calls waiting for an answer or a slot do not block it; force fences running executions, which resume on the new version`;
538
+ `calls waiting for an answer or a slot do not block it; force with the refusal's token, a reason and explicit user approval fences running executions, which resume on the new version`;
536
539
  }
537
540
  /** orchestratorView of the home's orchestrator ledger. */
538
541
  export function runningOrchestrator(home) {
@@ -553,7 +556,7 @@ export function slotsView(home, now = Date.now()) {
553
556
  holders.set(e.pool, (holders.get(e.pool) ?? 0) + 1);
554
557
  const names = [...new Set([...Object.keys(limits), ...holders.keys()])].sort();
555
558
  const slots = names.map(p => { const n = holders.get(p) ?? 0, limit = limits[p]?.slots; return typeof limit === "number" ? `${p} ${n}/${limit}` : `${p} ${n} (no limit)`; });
556
- return { ...orchestratorView(state), ...(slots.length ? { slots } : {}), ...(config ? { config: `${config.hash} since ${age(now - config.ts)} ago` } : {}),
559
+ return { ...orchestratorView(state, packageVersion(), now), ...(slots.length ? { slots } : {}), ...(config ? { config: `${config.hash} since ${age(now - config.ts)} ago` } : {}),
557
560
  ...(exhausted.length ? { exhausted } : {}),
558
561
  ...(rejected ? { configRejected: `${clip(rejected.error, 200)} (${age(now - rejected.ts)} ago); ${config ? config.hash : "the start settings"} stay in effect` } : {}) };
559
562
  }
@@ -5,8 +5,9 @@
5
5
  // revised {rid,revision,snapshot} commit publications; old pins remain immutable.
6
6
  // pruned {rid,wid,endedAt,bytes} (written by the engine) is decisive: the wid is gone, its create-intent and
7
7
  // created entries stay for identity (A1), and recovery finishes removing w/<wid> and its staging dirs.
8
- import { lstat, readdir, readFile, stat, rm } from 'node:fs/promises';
9
- import { readFileSync } from 'node:fs';
8
+ import { lstat, open, readdir, readFile, rename, stat, rm } from 'node:fs/promises';
9
+ import { createReadStream, readFileSync } from 'node:fs';
10
+ import { createHash } from 'node:crypto';
10
11
  import { basename, extname, join, relative } from 'node:path';
11
12
  import { discoverAgents } from "../compat/agents.js";
12
13
  import { parseFrontmatter } from "../compat/frontmatter.js";
@@ -16,6 +17,36 @@ import { openJournal, syncDirectory } from "../kernel/journal.js";
16
17
  import { publishFile } from "../kernel/mailbox.js";
17
18
  import { journalPath, pinnedDir, workflowDir } from "../paths.js";
18
19
  import { JT } from "../types.js";
20
+ /** Every file publishPins writes for one revision, relative to its directory (inputs given relative too). */
21
+ function publishedFiles(inputs, origin) {
22
+ return [...new Set(['script.js', 'args.json', 'agents.json', 'inputs.json', ...inputs, ...(origin ? ['origin.jsonl'] : [])])];
23
+ }
24
+ /** sha256 of a file, streamed. */
25
+ async function fileHash(path) {
26
+ const hash = createHash('sha256');
27
+ for await (const chunk of createReadStream(path))
28
+ hash.update(chunk);
29
+ return hash.digest('hex');
30
+ }
31
+ /** Replace `name` in `dir` atomically and durably (a cache record: last writer wins). */
32
+ async function replaceFile(dir, name, text) {
33
+ const temp = join(dir, `.${ulid()}.tmp`), file = await open(temp, 'wx', 0o600);
34
+ try {
35
+ await file.writeFile(text);
36
+ await file.sync();
37
+ }
38
+ finally {
39
+ await file.close();
40
+ }
41
+ try {
42
+ await rename(temp, join(dir, name));
43
+ }
44
+ catch (error) {
45
+ await rm(temp, { force: true });
46
+ throw error;
47
+ }
48
+ await syncDirectory(dir);
49
+ }
19
50
  // E3: errors of the machine rather than of the request; only these propagate, so the next intake retries the request.
20
51
  // Every other staging error (validation, missing input, ENAMETOOLONG, EACCES, ...) is deterministic: failure.json.
21
52
  // Conditions that clear without changing the request: retry at the next intake. Everything else is deterministic.
@@ -190,8 +221,10 @@ export class Store {
190
221
  body = req.body;
191
222
  snapshot = { hash: contentHash(req), pins: await prepareRun({ ...body, maxCalls: body.maxCalls ?? this.ledgers.config.k?.spawnBudget ?? 300 }, discovery, warnings),
192
223
  ...(warnings.length ? { warnings } : {}) };
193
- if (req.kind === 'revise')
194
- snapshot.pins.origin = this.workflows.get(req.body.wid)?.pins.origin;
224
+ if (req.kind === 'revise') {
225
+ const prior = this.workflows.get(req.body.wid)?.originPath;
226
+ snapshot.pins.origin = prior === undefined ? undefined : await readFile(prior, 'utf8');
227
+ }
195
228
  }
196
229
  catch (error) {
197
230
  if (transient(error))
@@ -279,6 +312,10 @@ export class Store {
279
312
  async publishPins(wid, revision, pins) {
280
313
  const root = pinnedDir(this.ledgers.home, wid), dir = revision === 1 ? root : join(root, `r${revision}`);
281
314
  const publish = async (directory, name, bytes) => {
315
+ // Already published (a restart): compare without writing and syncing another copy.
316
+ const existing = await readFile(join(directory, name)).catch(() => undefined);
317
+ if (existing?.equals(typeof bytes === 'string' ? Buffer.from(bytes) : bytes))
318
+ return;
282
319
  if (await publishFile(directory, name, bytes) === 'conflict')
283
320
  throw new Error(`Pinned content conflict: ${directory}/${name}`);
284
321
  };
@@ -294,15 +331,57 @@ export class Store {
294
331
  inputs[name] = join(dir, 'inputs', file);
295
332
  }
296
333
  await publish(dir, 'inputs.json', JSON.stringify(inputs));
297
- return { pins, scriptPath: join(dir, 'script.js'), inputs };
334
+ const { origin, inputs: _bytes, ...kept } = pins;
335
+ return { pins: { ...kept, inputs: {} }, scriptPath: join(dir, 'script.js'), inputs, originPath: origin === undefined ? undefined : join(dir, 'origin.jsonl') };
336
+ }
337
+ /** A1: The pinned files of one revision. The first publication verifies the staged snapshot and ends with `pins.json`:
338
+ * the in-memory pins and the sha256 of every published file, keyed by the snapshot hash. Later starts read that small
339
+ * record instead of parsing and re-publishing the snapshot (which can hold a multi-megabyte origin branch per
340
+ * workflow), and use it only when it is well formed, names this snapshot, its pins hash to its digest and every file
341
+ * hashes as recorded — otherwise they take the snapshot path, whose byte comparison reports a changed file as a
342
+ * conflict, and record it again. */
343
+ async pinnedFiles(wid, revision, intent) {
344
+ const dir = revision === 1 ? pinnedDir(this.ledgers.home, wid) : join(pinnedDir(this.ledgers.home, wid), `r${revision}`);
345
+ const ref = intent.pins ? undefined : intent.snapshot;
346
+ if (ref) {
347
+ const files = await this.recorded(dir, ref.hash).catch(() => undefined);
348
+ if (files)
349
+ return files;
350
+ }
351
+ const files = await this.publishPins(wid, revision, await this.pinned(intent));
352
+ if (ref) {
353
+ const hashes = {};
354
+ for (const name of publishedFiles(Object.values(files.inputs).map(path => relative(dir, path)), files.originPath !== undefined))
355
+ hashes[name] = await fileHash(join(dir, name));
356
+ const body = { snapshot: ref.hash, pins: files.pins,
357
+ inputs: Object.fromEntries(Object.entries(files.inputs).map(([name, path]) => [name, relative(dir, path)])), origin: files.originPath !== undefined, files: hashes };
358
+ await replaceFile(dir, 'pins.json', JSON.stringify({ ...body, digest: contentHash(body) }));
359
+ }
360
+ return files;
361
+ }
362
+ /** The files `pins.json` in `dir` records for snapshot `hash`, when the record and every file verify; else undefined. */
363
+ async recorded(dir, hash) {
364
+ const { digest, ...body } = JSON.parse(await readFile(join(dir, 'pins.json'), 'utf8'));
365
+ // The digest covers everything the record decides (pins, input names → files, origin, file hashes).
366
+ if (body.snapshot !== hash || typeof digest !== 'string' || contentHash(body) !== digest)
367
+ return undefined;
368
+ if (!body.pins || typeof body.pins !== 'object' || !body.inputs || typeof body.inputs !== 'object' || !body.files || typeof body.files !== 'object')
369
+ return undefined;
370
+ const names = publishedFiles(Object.values(body.inputs).map(String), body.origin === true);
371
+ if (names.length !== Object.keys(body.files).length || names.some(name => !Object.hasOwn(body.files, name)))
372
+ return undefined;
373
+ for (const name of names)
374
+ if (body.files[name] !== await fileHash(join(dir, name)))
375
+ return undefined;
376
+ return { pins: body.pins, scriptPath: join(dir, 'script.js'), inputs: Object.fromEntries(Object.entries(body.inputs).map(([name, file]) => [name, join(dir, String(file))])),
377
+ originPath: body.origin === true ? join(dir, 'origin.jsonl') : undefined };
298
378
  }
299
379
  /** P14, A2: Publish a new revision only after the engine has retired its predecessor. */
300
380
  async revise(intent) {
301
381
  const wf = this.workflows.get(intent.wid);
302
382
  if (wf.revision >= Number(intent.revision))
303
383
  return;
304
- const pins = await this.pinned(intent), revision = Number(intent.revision);
305
- const files = await this.publishPins(wf.wid, revision, pins);
384
+ const revision = Number(intent.revision), files = await this.pinnedFiles(wf.wid, revision, intent);
306
385
  await wf.journal.append('revised', { rid: intent.rid, revision, snapshot: intent.snapshot });
307
386
  Object.assign(wf, files, { revision });
308
387
  }
@@ -315,8 +394,7 @@ export class Store {
315
394
  : wf.journal.entries().find(e => e.type === 'revised' && e.revision === revision);
316
395
  if (!intent)
317
396
  throw new Error(`Missing pinned revision: ${wf.wid}@${revision}`);
318
- const pins = await this.pinned(intent);
319
- return { ...wf, revision, ...await this.publishPins(wf.wid, revision, pins) };
397
+ return { ...wf, revision, ...await this.pinnedFiles(wf.wid, revision, intent) };
320
398
  }
321
399
  /** A1, housekeeping: Wids with a committed pruned entry; they are gone and never materialize again. */
322
400
  pruned() { return new Set(this.ledgers.orch.entries().filter(e => e.type === 'pruned').map(e => String(e.wid))); }
@@ -368,7 +446,7 @@ export class Store {
368
446
  return existing;
369
447
  if (this.pruned().has(wid))
370
448
  throw new Error(`Workflow ${wid} was pruned`);
371
- const pins = await this.pinned(intent), files = await this.publishPins(wid, 1, pins);
449
+ const files = await this.pinnedFiles(wid, 1, intent);
372
450
  const journal = await openJournal(journalPath(this.ledgers.home, wid));
373
451
  const wf = { wid, revision: 1, origin: intent.origin, cwd: intent.cwd, journal, ...files };
374
452
  this.workflows.set(wid, wf);
@@ -377,7 +455,7 @@ export class Store {
377
455
  ...(intent.name !== undefined ? { name: intent.name } : {}) });
378
456
  const revised = journal.entries().findLast(e => e.type === 'revised');
379
457
  if (revised)
380
- Object.assign(wf, await this.publishPins(wid, Number(revised.revision), await this.pinned(revised)), { revision: Number(revised.revision) });
458
+ Object.assign(wf, await this.pinnedFiles(wid, Number(revised.revision), revised), { revision: Number(revised.revision) });
381
459
  if (!this.ledgers.orch.entries().some(e => e.type === JT.created && e.rid === intent.rid))
382
460
  await this.ledgers.orch.append(JT.created, { rid: intent.rid, wid, origin: intent.origin });
383
461
  return wf;
@@ -0,0 +1,87 @@
1
+ // R1: Caller-chosen request ids. A program names a run/send/stop with `<id>`; its kernel rid is `req:<id>` (fits the
2
+ // mailbox rid pattern), unique per DSA_HOME across kinds and senders. A retry with the same id and the same content
3
+ // (spec_digest) gets the first outcome; a different content is a request-conflict and is never published.
4
+ // The check-and-send is serialized per home by the OS lock <home>/requests.lock (CLI and tool senders alike); no
5
+ // journal entry type is added: the admitted orch `request` entry and the senders' outbox `sent` entries are the record.
6
+ import { readdir } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { setTimeout as delay } from 'node:timers/promises';
9
+ import { contentHash } from "./kernel/ids.js";
10
+ import { readJournalSnapshot } from "./kernel/journal.js";
11
+ import { publishRequest } from "./kernel/mailbox.js";
12
+ import { OsLock } from "./platform/lock.js";
13
+ import { orchInbox, orchLedger, outboxRoot } from "./paths.js";
14
+ export const REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,123}$/;
15
+ const PREFIX = 'req:';
16
+ /** R1: `<id>` → its rid `req:<id>`; ids are 1–124 chars `[A-Za-z0-9][A-Za-z0-9._:-]*`. */
17
+ export function requestRid(id) {
18
+ if (!REQUEST_ID.test(id))
19
+ throw new Error(`invalid request id ${JSON.stringify(id)}: 1-124 characters [A-Za-z0-9][A-Za-z0-9._:-]*`);
20
+ return PREFIX + id;
21
+ }
22
+ /** R1: The request id of a `req:<id>` rid; undefined for any other rid (ULIDs never contain ':'). */
23
+ export function requestId(rid) { return rid.startsWith(PREFIX) ? rid.slice(PREFIX.length) : undefined; }
24
+ /** R1: spec_digest = contentHash({kind, body, cond}) (cond omitted when absent). A run's body.origin (the pi session
25
+ * branch offered for context:"fork") is delivery metadata, not spec: it differs on every turn, so it is not hashed. */
26
+ export function specDigest(req) {
27
+ let body = req.body;
28
+ if (req.kind === 'run' && body && typeof body === 'object' && !Array.isArray(body)) {
29
+ const { origin: _, ...rest } = body;
30
+ body = rest;
31
+ }
32
+ return contentHash({ kind: req.kind, body, cond: req.cond });
33
+ }
34
+ /** R1: The envelope recorded for `rid`: the orchestrator's admitted copy (ledger `request`, kept after prune), else any
35
+ * sender's outbox `sent` entry (published or about to be). Read-only. */
36
+ export async function findRequest(home, rid) {
37
+ const admitted = readJournalSnapshot(orchLedger(home)).find(e => e.type === 'request' && e.request.rid === rid);
38
+ if (admitted)
39
+ return { request: admitted.request, admitted: true };
40
+ const dir = join(outboxRoot(home), 'outbox');
41
+ const names = await readdir(dir).catch(error => { if (error.code === 'ENOENT')
42
+ return []; throw error; });
43
+ for (const name of names.filter(n => n.endsWith('.jsonl')).sort()) {
44
+ const sent = readJournalSnapshot(join(dir, name)).find(e => e.type === 'sent' && e.request.rid === rid);
45
+ if (sent)
46
+ return { request: sent.request, admitted: false };
47
+ }
48
+ return undefined;
49
+ }
50
+ /** A lock was not free within 10 s: nothing was submitted by this attempt; a retry with the same id is safe. */
51
+ export class RequestsBusy extends Error {
52
+ name = 'RequestsBusy';
53
+ }
54
+ /** R1, P5: Check-then-send under the home-wide request-id lock (innermost: taken after a sender's own lock), so no two
55
+ * senders publish one rid and no second envelope with an existing rid and other content reaches the inbox (it would
56
+ * stall its sender's sequence). Same content: the recorded envelope stands (republished when it is this sender's and
57
+ * pending; `sent` false). */
58
+ export async function sendIdentified(home, outbox, sender, rid, kind, body, cond) {
59
+ const digest = specDigest({ kind, body, cond }), path = join(home, 'requests.lock'), locker = new OsLock(), deadline = performance.now() + 10_000;
60
+ let lock = await locker.tryAcquire(path);
61
+ while (!lock && performance.now() < deadline) {
62
+ await delay(25);
63
+ lock = await locker.tryAcquire(path);
64
+ }
65
+ if (!lock)
66
+ throw new RequestsBusy('request ids are busy; retry the command');
67
+ try {
68
+ const prior = await findRequest(home, rid);
69
+ if (prior && specDigest(prior.request) !== digest)
70
+ return { conflict: prior.request, digest: specDigest(prior.request) };
71
+ if (prior) {
72
+ // Its own envelope is re-offered verbatim (Outbox.send returns it and republishes it while pending).
73
+ // Another sender's envelope not admitted yet is published verbatim (publishing is idempotent per rid): a sender
74
+ // that died between recording and publishing it would otherwise leave the id pending for ever.
75
+ let request = prior.request;
76
+ if (prior.request.from === sender)
77
+ request = await outbox.send(prior.request.to, prior.request.kind, prior.request.body, prior.request.cond, { rid });
78
+ else if (!prior.admitted)
79
+ await publishRequest(orchInbox(home), prior.request);
80
+ return { request, digest, sent: false };
81
+ }
82
+ return { request: await outbox.send('orch', kind, body, cond, { rid }), digest, sent: true };
83
+ }
84
+ finally {
85
+ await lock.release();
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.18",
3
+ "version": "1.0.21",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",