pi-durable-subagents 1.0.19 → 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.
package/dist/cli/main.js CHANGED
@@ -13,7 +13,8 @@ import { doctor, renderDoctor, size } from "./doctor.js";
13
13
  import { smoke } from "./smoke.js";
14
14
  import { serviceFiles, manageService } from "./service.js";
15
15
  import { leaseLines, leaseState } from "../platform/lease.js";
16
- import { currentOrchestrator, legacyRestart, waitExit, waitSuccessor } from "./restart.js";
16
+ import { currentOrchestrator, decidedBy, legacyRestart, cliInitiator, waitExit, waitSuccessor } from "./restart.js";
17
+ import { restartInputError } from "../orchestrator/restart.js";
17
18
  const commands = ["smoke", "tail", "status", "events", "start", "resume", "drain", "stop", "stop-all", "prune", "restart", "leases", "doctor", "install-service", "uninstall-service", "help"];
18
19
  /** P25: Reject ambiguous CLI arguments before any durable action. */
19
20
  export function parseArgs(args) {
@@ -33,12 +34,25 @@ export function parseArgs(args) {
33
34
  olderThanDays = Number(value);
34
35
  rest = [...rest.slice(0, at), ...rest.slice(at + 2)];
35
36
  }
36
- const force = rest.includes("--force");
37
- if (force && command !== "restart")
38
- throw new Error("--force is only supported by restart");
39
- if (rest.filter(a => a === "--force").length > 1)
40
- throw new Error("Unknown or repeated option");
41
- rest = rest.filter(a => a !== "--force");
37
+ let force, reason;
38
+ for (const flag of ["--force", "--reason"]) {
39
+ const index = rest.indexOf(flag);
40
+ if (index < 0)
41
+ continue;
42
+ if (command !== "restart")
43
+ throw new Error(`${flag} is only supported by restart`);
44
+ if (rest.filter(a => a === flag).length > 1)
45
+ throw new Error("Unknown or repeated option");
46
+ const value = rest[index + 1], present = value !== undefined && !value.startsWith("--");
47
+ if (flag === "--force")
48
+ force = present ? value : true;
49
+ else {
50
+ if (!present)
51
+ throw new Error("--reason needs text");
52
+ reason = value;
53
+ }
54
+ rest = [...rest.slice(0, index), ...rest.slice(index + (present ? 2 : 1))];
55
+ }
42
56
  const json = rest.includes("--json"), dryRun = rest.includes("--dry-run");
43
57
  if (dryRun && !["install-service", "uninstall-service"].includes(command))
44
58
  throw new Error("--dry-run requires a service command");
@@ -56,7 +70,7 @@ export function parseArgs(args) {
56
70
  throw new Error("Invalid workflow id");
57
71
  if (target && olderThanDays !== undefined)
58
72
  throw new Error("prune takes a workflow id or --older-than, not both");
59
- return { command, json, ...(force ? { force } : {}), ...(dryRun ? { dryRun } : {}), ...(target ? { target } : {}), ...(olderThanDays !== undefined ? { olderThanDays } : {}) };
73
+ return { command, json, ...(force !== undefined ? { force } : {}), ...(reason !== undefined ? { reason } : {}), ...(dryRun ? { dryRun } : {}), ...(target ? { target } : {}), ...(olderThanDays !== undefined ? { olderThanDays } : {}) };
60
74
  }
61
75
  const clip = (text, n) => text.length > n ? `${text.slice(0, n)}…` : text;
62
76
  /** P25, T10: Render journal-derived status (one line per call, last output line only) without live orchestrator memory. */
@@ -118,10 +132,17 @@ export function serviceEntryError(entry) {
118
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.`;
119
133
  return undefined;
120
134
  }
121
- 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 | prune [wid] [--older-than <days>] | restart [--force] | 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] | 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]";
122
136
  /** Restart: the orchestrator exits when no execution runs (or `force`) and the installed version takes over. */
123
137
  async function restartCommand(home, env, write, options) {
124
- const starter = options.starter ?? startOrchestrator, old = currentOrchestrator(home);
138
+ const body = { ...(typeof options.force === "string" ? { token: options.force } : options.force === true ? { force: true } : {}), ...(options.reason !== undefined ? { reason: options.reason } : {}), initiator: cliInitiator(env) };
139
+ const invalid = restartInputError(body, env.DSA_EXEC !== undefined);
140
+ if (invalid) {
141
+ write(`restart refused: ${invalid}`);
142
+ return 1;
143
+ }
144
+ const starter = options.starter ?? startOrchestrator;
145
+ let old = currentOrchestrator(home);
125
146
  if (!old) {
126
147
  write(await start(home, env, starter) ? "restart: no orchestrator was running; started one for the pending work" : "restart: no orchestrator is running (one starts when work is submitted)");
127
148
  return 0;
@@ -129,23 +150,32 @@ async function restartCommand(home, env, write, options) {
129
150
  const waitMs = options.waitMs ?? 30_000;
130
151
  if (!old.restart) {
131
152
  // An orchestrator older than the restart request: check its journals here and end it with SIGTERM.
132
- const legacy = legacyRestart(home, old, options.force);
153
+ const legacy = legacyRestart(home, old, body, { subagent: env.DSA_EXEC !== undefined });
133
154
  if (!legacy.applied) {
134
155
  write(`restart refused: ${legacy.reason}`);
135
156
  return 1;
136
157
  }
137
158
  }
138
159
  else {
139
- const [req] = await submit(home, "restart", undefined, env, { force: options.force });
140
- const outcome = await resolution(home, req.rid, waitMs);
160
+ const [req] = await submit(home, "restart", undefined, env, { restart: body });
161
+ // The orchestrator reads requests only after recovering its workflows (it may itself have just started): keep
162
+ // waiting while one runs. An undecided request stays durable and is decided later — say so, never resubmit.
163
+ let outcome = await resolution(home, req.rid, waitMs);
164
+ if (!outcome && currentOrchestrator(home)) {
165
+ write(`restart ${req.rid}: submitted; the orchestrator has not reached it yet (it may still be recovering) — waiting`);
166
+ const deadline = performance.now() + (options.pendingMs ?? 600_000);
167
+ while (!outcome && currentOrchestrator(home) && performance.now() < deadline)
168
+ outcome = await resolution(home, req.rid, Math.min(waitMs, 5_000));
169
+ }
141
170
  if (!outcome) {
142
- write(`submitted restart ${req.rid}; not resolved within ${Math.round(waitMs / 1000)} s (see: pi-durable-subagents doctor)`);
143
- return 1;
171
+ write(`restart ${req.rid} is still pending: it is decided when an orchestrator reaches it (do not resubmit; see: pi-durable-subagents status)`);
172
+ return 75;
144
173
  }
145
174
  if (outcome.type === "rejected") {
146
175
  write(`restart refused: ${outcome.reason}`);
147
176
  return 1;
148
177
  }
178
+ old = decidedBy(home, req.rid) ?? old;
149
179
  }
150
180
  if (!await waitExit(old, Math.max(waitMs, 60_000))) {
151
181
  write(`restart: orchestrator ${old.version} (pid ${old.pid}) is still shutting down; see: pi-durable-subagents status`);
@@ -164,7 +194,14 @@ export async function main(args = process.argv.slice(2), options = {}) {
164
194
  }
165
195
  if (args[0] === "chaos")
166
196
  return (await import("./chaos/index.js")).chaos(args.slice(1), options.env ?? process.env, options.write);
167
- const { command, target, json, dryRun, olderThanDays, force } = parseArgs(args), env = options.env ?? process.env;
197
+ // R1–R3: program-facing commands named by request ids (strict flags of their own).
198
+ if (["run", "send", "describe"].includes(args[0]) || (args[0] === "stop" && args.includes("--request"))) {
199
+ const env = options.env ?? process.env, requests = await import("./requests.js");
200
+ const ctx = { home: dsaHome(env), env, write: options.write ?? ((line) => console.log(line)), starter: options.starter, waitMs: options.waitMs, cwd: options.cwd, stdin: options.stdin };
201
+ return args[0] === "run" ? requests.runCommand(args.slice(1), ctx) : args[0] === "send" ? requests.sendCommand(args.slice(1), ctx)
202
+ : args[0] === "stop" ? requests.stopCommand(args.slice(1), ctx) : requests.describeCommand(args.slice(1), ctx);
203
+ }
204
+ const { command, target, json, dryRun, olderThanDays, force, reason } = parseArgs(args), env = options.env ?? process.env;
168
205
  const home = dsaHome(env), write = options.write ?? (line => console.log(line));
169
206
  if (command === "help") {
170
207
  write(HELP);
@@ -256,7 +293,7 @@ export async function main(args = process.argv.slice(2), options = {}) {
256
293
  return 0;
257
294
  }
258
295
  if (command === "restart")
259
- return restartCommand(home, env, write, { force: force === true, starter: options.starter, waitMs: options.waitMs });
296
+ return restartCommand(home, env, write, { force, reason, starter: options.starter, waitMs: options.waitMs, pendingMs: options.pendingMs });
260
297
  for (const req of await submit(home, command, target, env))
261
298
  write(`submitted ${req.kind} ${req.rid}`);
262
299
  return 0;
@@ -0,0 +1,348 @@
1
+ // R1–R4: Program-facing commands named by caller-chosen request ids — `run|send|stop --request <id>` and `describe`.
2
+ // Exit codes: 0 decided (applied/created), 1 rejected or invalid, 3 request-conflict (the id names other content),
3
+ // 75 not decided within --wait-ms (retry with the same id and content: safe).
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
7
+ import { readJournalSnapshot } from "../kernel/journal.js";
8
+ import { reduceLifecycle } from "../kernel/lifecycle.js";
9
+ import { journalPath, orchLedger } from "../paths.js";
10
+ import { findRequest, REQUEST_ID, requestId, requestRid, RequestsBusy, specDigest } from "../requests.js";
11
+ import { isLive, slotsView, workflowSnapshot } from "../orchestrator/snapshot.js";
12
+ import { leaseCalls, leaseState } from "../platform/lease.js";
13
+ import { checkAgents, request } from "../agent/main/tool.js";
14
+ import { discoverAgents } from "../compat/agents.js";
15
+ import { JT } from "../types.js";
16
+ import { startOrchestrator, submitIdentified } from "./control.js";
17
+ export const EXIT = { ok: 0, rejected: 1, conflict: 3, pending: 75 };
18
+ /** Strict `--name value` / `--flag` parsing; unknown or repeated options are errors. */
19
+ function flags(args, spec) {
20
+ const values = {}, positionals = [];
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+ if (!arg.startsWith("--")) {
24
+ positionals.push(arg);
25
+ continue;
26
+ }
27
+ const name = arg.slice(2), kind = spec[name];
28
+ if (!kind || Object.hasOwn(values, name))
29
+ throw new Error(`Unknown or repeated option ${arg}`);
30
+ if (kind === "flag") {
31
+ values[name] = true;
32
+ continue;
33
+ }
34
+ const value = args[++i];
35
+ if (value === undefined)
36
+ throw new Error(`${arg} needs a value`);
37
+ values[name] = value;
38
+ }
39
+ return { values, positionals };
40
+ }
41
+ const text = (values, name) => typeof values[name] === "string" ? values[name] : undefined;
42
+ function waitMs(values, ctx) {
43
+ const raw = text(values, "wait-ms");
44
+ if (raw !== undefined && !/^\d+$/.test(raw))
45
+ throw new Error("--wait-ms needs a non-negative integer");
46
+ return raw !== undefined ? Number(raw) : ctx.waitMs ?? 60_000;
47
+ }
48
+ function ledger(home) { return readJournalSnapshot(orchLedger(home)); }
49
+ function decision(entries, rid) {
50
+ const records = entries.filter(e => [JT.admitted, JT.applied, JT.rejected, JT.withdrawn].includes(e.type));
51
+ return reduceLifecycle(records).resolved.get(rid);
52
+ }
53
+ const createdBy = (entries, rid) => entries.find(e => e.type === JT.created && e.rid === rid);
54
+ async function stdin() { const chunks = []; for await (const chunk of process.stdin)
55
+ chunks.push(chunk); return Buffer.concat(chunks).toString("utf8"); }
56
+ /** R3: The state of a request id (`{request}`) or a workflow (`{wid}`), with full texts (no clipping). */
57
+ export async function describe(home, key, now = Date.now()) {
58
+ const entries = ledger(home);
59
+ if ("wid" in key)
60
+ return describeWorkflow(home, key.wid, entries, now);
61
+ const rid = requestRid(key.request), found = await findRequest(home, rid);
62
+ if (!found)
63
+ return { state: "absent", request: key.request };
64
+ const head = { request: key.request, kind: found.request.kind, spec_digest: specDigest(found.request) };
65
+ const decided = decision(entries, rid), created = createdBy(entries, rid);
66
+ if (found.request.kind === "run" && created)
67
+ return { ...await describeWorkflow(home, String(created.wid), entries, now), ...head };
68
+ if (decided?.type === "rejected")
69
+ return { state: "rejected", ...head, reason: String(decided.reason) };
70
+ // A send or stop has no workflow of its own: its decision is the outcome.
71
+ if (decided && found.request.kind !== "run")
72
+ return { state: "applied", ...head };
73
+ return { state: "pending", ...head };
74
+ }
75
+ function describeWorkflow(home, wid, entries, now) {
76
+ const pruned = entries.find(e => e.type === "pruned" && e.wid === wid);
77
+ if (pruned)
78
+ 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 } : {}) };
80
+ if (!/^[^/\\\0]+$/.test(wid) || wid === "." || wid === ".." || !existsSync(journalPath(home, wid)))
81
+ return { state: "absent", wid };
82
+ const wf = workflowSnapshot(home, wid), journal = readJournalSnapshot(journalPath(home, wid));
83
+ const created = entries.find(e => e.type === JT.created && e.wid === wid), id = created ? requestId(String(created.rid)) : undefined;
84
+ const admitted = id ? entries.find(e => e.type === "request" && e.request.rid === created.rid)?.request : undefined;
85
+ const slots = slotsView(home, now), leases = leaseCalls(leaseState(home), now);
86
+ const line = (lines, model) => { const provider = model?.split("/")[0]; return provider ? lines?.find(l => l.startsWith(`${provider} `)) : undefined; };
87
+ const latest = [...new Map(wf.calls.map(c => [c.key, c])).values()];
88
+ const calls = latest.map((c) => {
89
+ const r = c.result, waiting = r ? undefined : {
90
+ ...(c.writerWait ? { writerWait: c.writerWait } : {}), ...(leases.get(c.callId) ? { lease: leases.get(c.callId) } : {}),
91
+ ...(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 } : {})
93
+ };
94
+ return { key: c.key, gen: c.gen, phase: c.phase, agent: c.agent, ...(c.model ? { model: c.model } : {}),
95
+ ...(r ? { status: r.status, ok: r.ok, ...(r.error ? { error: r.error } : {}), output: r.output, ...(r.data !== undefined ? { data: r.data } : {}) } : {}),
96
+ ...(waiting && Object.keys(waiting).length ? { waiting } : {}) };
97
+ });
98
+ const keyOf = (call) => call?.replace(/^[^/]*\//, "").replace(/@\d+$/, "");
99
+ const questions = wf.attention.filter(a => a.kind === "question").map(a => ({ ...(a.qid ? { qid: a.qid } : {}), rev: a.rev, to: `${wid}/${keyOf(a.call)}`, ...(a.call ? { call: a.call } : {}), text: a.text }));
100
+ 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
+ const state = questions.length ? "asking" : isLive(wf) || wf.status === "parked" ? "running" : "sealed";
102
+ 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 } : {}),
104
+ calls, ...(questions.length ? { questions } : {}), ...(attention.length ? { attention } : {}), ...(fence ? { lastFence: fence } : {}) };
105
+ }
106
+ /** R3, best effort: why the latest execution fence happened. restart-force: a forced restart listed the execution as live;
107
+ * orchestrator-crash: the execution was launched before an orchestrator start that is not preceded by a clean exit and
108
+ * fenced after it (startup recovery); otherwise process-died (the child or its host went away, or a drain/stop fenced it). */
109
+ export function lastFence(journal, orch) {
110
+ const fence = journal.findLast(e => e.type === JT.fenced);
111
+ if (!fence)
112
+ return undefined;
113
+ const exec = String(fence.exec), at = Number(fence.ts);
114
+ if (orch.some(e => e.type === "restart" && e.force === true && Array.isArray(e.live) && e.live.includes(exec)))
115
+ return { at, exec, reason: "restart-force" };
116
+ const launched = journal.find(e => e.type === JT.exec && e.exec === exec), starts = orch.filter(e => e.type === "orchestrator");
117
+ const recovery = starts.findLast(s => Number(s.ts) <= at);
118
+ if (launched && recovery && Number(launched.ts) < Number(recovery.ts)) {
119
+ const prior = orch.filter(e => Number(e.seq) < Number(recovery.seq));
120
+ const lastStart = prior.findLast(e => e.type === "orchestrator"), cleanExit = lastStart && prior.some(e => e.type === "orchestrator-exit" && Number(e.seq) > Number(lastStart.seq));
121
+ if (!cleanExit)
122
+ return { at, exec, reason: "orchestrator-crash" };
123
+ }
124
+ return { at, exec, reason: "process-died" };
125
+ }
126
+ export function renderDescription(d) {
127
+ 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}` : ""}`];
128
+ if (d.pruned)
129
+ lines.push(` pruned: ${d.pruned.status ?? "?"} at ${new Date(d.pruned.endedAt ?? 0).toISOString()}`);
130
+ if (d.error)
131
+ lines.push(` error: ${d.error}`);
132
+ for (const c of d.calls ?? []) {
133
+ lines.push(` ${c.key}@${c.gen} ${c.agent} ${c.phase}${c.status ? ` ${c.status}` : ""}${c.model ? ` (${c.model})` : ""}${c.error ? ` — ${c.error}` : ""}`);
134
+ if (c.waiting)
135
+ lines.push(` waiting: ${JSON.stringify(c.waiting)}`);
136
+ if (c.data !== undefined)
137
+ lines.push(` data: ${JSON.stringify(c.data)}`);
138
+ if (c.output)
139
+ lines.push(...c.output.split("\n").map(l => ` | ${l}`));
140
+ }
141
+ for (const q of d.questions ?? [])
142
+ lines.push(` question ${q.to} qid=${q.qid} rev=${q.rev}:`, ...q.text.split("\n").map(l => ` ${l}`));
143
+ for (const a of d.attention ?? [])
144
+ lines.push(` ${a.kind}${a.call ? ` ${a.call}` : ""}: ${a.text}`);
145
+ if (d.lastFence)
146
+ lines.push(` last fence: ${d.lastFence.exec} ${d.lastFence.reason} at ${new Date(d.lastFence.at).toISOString()}`);
147
+ return lines.join("\n");
148
+ }
149
+ export async function describeCommand(args, ctx) {
150
+ const { values, positionals } = flags(args, { key: "value", json: "flag" });
151
+ const key = text(values, "key");
152
+ if ((key === undefined) === (positionals.length !== 1) || positionals.length > 1)
153
+ throw new Error("describe needs --key <request id> or a workflow id");
154
+ const d = await describe(ctx.home, key !== undefined ? { request: key } : { wid: positionals[0] });
155
+ ctx.write(values.json ? JSON.stringify(d, null, 2) : renderDescription(d));
156
+ return 0;
157
+ }
158
+ /** Wait (bounded) for the request's decision: a run's `created` wid, or the lifecycle resolution. */
159
+ async function outcome(home, rid, run, timeoutMs) {
160
+ const deadline = performance.now() + timeoutMs;
161
+ for (;;) {
162
+ const entries = ledger(home), created = run ? createdBy(entries, rid) : undefined;
163
+ if (created)
164
+ return { type: "applied", wid: String(created.wid) };
165
+ const decided = decision(entries, rid);
166
+ if (decided?.type === "rejected")
167
+ return { type: "rejected", reason: String(decided.reason) };
168
+ if (decided && !run)
169
+ return { type: "applied" };
170
+ if (performance.now() >= deadline)
171
+ return undefined;
172
+ await delay(Math.min(100, Math.max(0, deadline - performance.now())));
173
+ }
174
+ }
175
+ async function conflict(ctx, id, original, json) {
176
+ const d = await describe(ctx.home, { request: id });
177
+ const reply = { request: id, error: "request-conflict", ...(d.wid ? { wid: d.wid } : {}), spec_digest: original, state: d.state };
178
+ ctx.write(json ? JSON.stringify(reply) : `${id}: request-conflict — this id names other content (${d.state}${d.wid ? `, ${d.wid}` : ""}); retry with the stored spec bytes or use a new id`);
179
+ return EXIT.conflict;
180
+ }
181
+ function pending(ctx, id, json, why = "") {
182
+ ctx.write(json ? JSON.stringify({ request: id, pending: true, ...(why ? { reason: why } : {}) }) : `${id}: submitted; not decided yet${why ? ` (${why})` : ""} — retry with the same id (safe)`);
183
+ return EXIT.pending;
184
+ }
185
+ /** Submit, then wait; a decision whose admitted envelope has other content (another sender won the id) is a conflict. */
186
+ async function submitAndWait(ctx, id, kind, body, cond, wait, json) {
187
+ // R2: `created` is false only when the id was already decided before this invocation submitted (decisions are
188
+ // monotonic); racing first attempts may all report created — the wid is what identifies the run.
189
+ const rid = requestRid(id), earlier = Boolean(await outcome(ctx.home, rid, kind === "run", 0));
190
+ let sent;
191
+ try {
192
+ sent = await submitIdentified(ctx.home, rid, kind, body, cond, ctx.env, ctx.starter);
193
+ }
194
+ catch (error) {
195
+ // Exit 1 means decided (rejected) or a usage error. A busy lock submitted nothing, and a failure after the envelope
196
+ // was recorded (starting the orchestrator, say) leaves it submitted: both are "not decided yet — retry the same id".
197
+ const recorded = await findRequest(ctx.home, rid).catch(() => undefined);
198
+ if (error instanceof RequestsBusy || recorded && specDigest(recorded.request) === specDigest({ kind, body, cond }))
199
+ return { code: pending(ctx, id, json, error instanceof RequestsBusy ? "busy" : `submitted, then: ${error.message ?? String(error)}`) };
200
+ throw error;
201
+ }
202
+ if ("conflict" in sent)
203
+ return { code: await conflict(ctx, id, sent.digest, json) };
204
+ const result = await outcome(ctx.home, rid, kind === "run", wait);
205
+ if (!result)
206
+ return { code: pending(ctx, id, json) };
207
+ const admitted = (await findRequest(ctx.home, rid))?.request;
208
+ if (admitted && specDigest(admitted) !== sent.digest)
209
+ return { code: await conflict(ctx, id, specDigest(admitted), json) };
210
+ return { sent, outcome: result, earlier };
211
+ }
212
+ const forks = (spec) => [spec, ...["tasks", "chain"].flatMap(k => Array.isArray(spec[k]) ? spec[k] : [])]
213
+ .some(s => s && typeof s === "object" && s.context === "fork");
214
+ /** R2: `run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]`. The spec is the `subagents` run
215
+ * form ({agent,task,…} or {tasks|chain:[…],…}); it is validated by the tool's own normalizer and agent check. */
216
+ export async function runCommand(args, ctx) {
217
+ const { values, positionals } = flags(args, { request: "value", spec: "value", cwd: "value", json: "flag", "wait-ms": "value" });
218
+ const id = text(values, "request"), file = text(values, "spec"), json = values.json === true, wait = waitMs(values, ctx);
219
+ if (!id || !file || positionals.length)
220
+ throw new Error("usage: run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]");
221
+ requestRid(id);
222
+ const bytes = file === "-" ? await (ctx.stdin ?? stdin)() : readFileSync(resolve(ctx.cwd ?? process.cwd(), file), "utf8");
223
+ let spec;
224
+ try {
225
+ spec = JSON.parse(bytes);
226
+ }
227
+ catch (error) {
228
+ throw new Error(`--spec is not JSON: ${error.message}`);
229
+ }
230
+ if (!spec || typeof spec !== "object" || Array.isArray(spec))
231
+ throw new Error("--spec must be a JSON object");
232
+ if (spec.action !== undefined && spec.action !== "run")
233
+ throw new Error("--spec describes a run; action must be absent or \"run\"");
234
+ if (spec.request !== undefined)
235
+ throw new Error("the request id is --request, not a spec field");
236
+ if (forks(spec))
237
+ throw new Error("context \"fork\" needs a pi session to fork; it is not available to run --request");
238
+ // RunBody.cwd = spec.cwd ?? --cwd ?? the current directory, absolute before the digest.
239
+ const base = resolve(ctx.cwd ?? process.cwd(), text(values, "cwd") ?? "."), dir = typeof spec.cwd === "string" && spec.cwd ? resolve(base, spec.cwd) : base;
240
+ const normalized = request({ ...spec, action: "run", ...(typeof spec.cwd === "string" && spec.cwd ? { cwd: dir } : {}) }, dir);
241
+ const body = normalized.body;
242
+ checkAgents(body, () => discoverAgents(dir, { home: ctx.env.HOME || undefined, agentDir: ctx.env.PI_CODING_AGENT_DIR || undefined }).agents.map(a => a.name));
243
+ const done = await submitAndWait(ctx, id, "run", body, undefined, wait, json);
244
+ if ("code" in done)
245
+ return done.code;
246
+ if (done.outcome.type === "rejected") {
247
+ ctx.write(json ? JSON.stringify({ request: id, applied: false, reason: done.outcome.reason, spec_digest: done.sent.digest }) : `${id}: rejected ${done.outcome.reason}`);
248
+ return EXIT.rejected;
249
+ }
250
+ const reply = { request: id, wid: done.outcome.wid, created: !done.earlier, spec_digest: done.sent.digest };
251
+ ctx.write(json ? JSON.stringify(reply) : `${id} → ${reply.wid} (${reply.created ? "created" : "existing"})`);
252
+ return EXIT.ok;
253
+ }
254
+ /** `<run-id>` or `<wid>` → wid when that run id created one; `pending` when the run is known but has no workflow yet. */
255
+ async function widOf(home, head) {
256
+ if (!REQUEST_ID.test(head))
257
+ return { wid: head };
258
+ const rid = requestRid(head), created = createdBy(ledger(home), rid);
259
+ if (created)
260
+ return { wid: String(created.wid) };
261
+ const found = await findRequest(home, rid);
262
+ return found?.request.kind === "run" && decision(ledger(home), rid)?.type !== "rejected" ? { pending: true } : { wid: head };
263
+ }
264
+ /** R2: `--to <run-id>[/<key>] | <wid>/<key>` (+ `--call <key>`) → `<wid>/<key>`; a run of one call implies its key. */
265
+ async function target(home, to, call, prior) {
266
+ const cut = to.indexOf("/"), head = cut < 0 ? to : to.slice(0, cut), key = cut < 0 ? call : to.slice(cut + 1);
267
+ if (cut >= 0 && call !== undefined)
268
+ throw new Error("give the call key in --to or --call, not both");
269
+ const resolved = await widOf(home, head);
270
+ if ("pending" in resolved)
271
+ return resolved;
272
+ if (key)
273
+ return { to: `${resolved.wid}/${key}` };
274
+ // A retry addresses what the original did (the workflow may have been pruned since).
275
+ const before = prior?.body?.to;
276
+ if (typeof before === "string" && before.startsWith(`${resolved.wid}/`))
277
+ return { to: before };
278
+ if (!existsSync(journalPath(home, resolved.wid)))
279
+ return { to: resolved.wid };
280
+ const keys = [...new Set(workflowSnapshot(home, resolved.wid).calls.map(c => c.key))];
281
+ if (keys.length === 1)
282
+ return { to: `${resolved.wid}/${keys[0]}` };
283
+ throw new Error(`${to} has ${keys.length ? `calls ${keys.join(", ")}` : "no calls yet"}; name one with --call <key> or --to <wid>/<key>`);
284
+ }
285
+ /** R2: `send --request <id> --to <…> --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] --message <text|@file> [--model <m>]`. */
286
+ export async function sendCommand(args, ctx) {
287
+ const { values, positionals } = flags(args, { request: "value", to: "value", call: "value", kind: "value", qid: "value", rev: "value", message: "value", model: "value", json: "flag", "wait-ms": "value" });
288
+ const id = text(values, "request"), to = text(values, "to"), kind = text(values, "kind"), json = values.json === true, wait = waitMs(values, ctx);
289
+ if (!id || !to || !kind || positionals.length)
290
+ throw new Error("usage: send --request <id> --to <run-id|wid/key> --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] --message <text|@file> [--model <m>] [--json]");
291
+ const rid = requestRid(id), prior = (await findRequest(ctx.home, rid))?.request;
292
+ const where = await target(ctx.home, to, text(values, "call"), prior);
293
+ if ("pending" in where)
294
+ return pending(ctx, id, json, `run ${to.split("/")[0]} has no workflow yet`);
295
+ const raw = text(values, "message"), message = raw?.startsWith("@") ? readFileSync(resolve(ctx.cwd ?? process.cwd(), raw.slice(1)), "utf8") : raw;
296
+ const rev = text(values, "rev");
297
+ if (rev !== undefined && !/^\d+$/.test(rev))
298
+ throw new Error("--rev needs a positive integer");
299
+ let qid = text(values, "qid"), revision = rev !== undefined ? Number(rev) : undefined;
300
+ if (kind === "answer" && (qid === undefined || revision === undefined)) {
301
+ // The open question of the target call (or, on a retry, the one the original answered) supplies qid/rev.
302
+ const before = prior?.kind === "send" && prior.body.kind === "answer" && prior.body.to === where.to ? prior.cond : undefined;
303
+ const [wid, key] = [where.to.slice(0, where.to.indexOf("/")), where.to.slice(where.to.indexOf("/") + 1)];
304
+ const open = before ? [{ qid: before.qid, rev: before.rev }] : existsSync(journalPath(ctx.home, wid))
305
+ ? workflowSnapshot(ctx.home, wid).attention.filter(a => a.kind === "question" && a.call?.replace(/^[^/]*\//, "").replace(/@\d+$/, "") === key && (qid === undefined || a.qid === qid)) : [];
306
+ if (open.length !== 1)
307
+ throw new Error(open.length ? `several questions are open on ${where.to}; give --qid and --rev` : `no open question on ${where.to}${qid ? ` with qid ${qid}` : ""}`);
308
+ qid ??= open[0].qid;
309
+ revision ??= open[0].rev;
310
+ }
311
+ const normalized = request({ action: "send", to: where.to, kind, ...(message !== undefined ? { message } : {}), ...(text(values, "model") !== undefined ? { model: text(values, "model") } : {}),
312
+ ...(qid !== undefined ? { qid } : {}), ...(revision !== undefined ? { rev: revision } : {}) }, ctx.cwd ?? process.cwd());
313
+ const done = await submitAndWait(ctx, id, "send", normalized.body, normalized.cond, wait, json);
314
+ if ("code" in done)
315
+ return done.code;
316
+ return decided(ctx, id, done, json);
317
+ }
318
+ /** R2: `stop --request <id> <run-id|wid|wid/key|callId>`. */
319
+ export async function stopCommand(args, ctx) {
320
+ const { values, positionals } = flags(args, { request: "value", json: "flag", "wait-ms": "value" });
321
+ const id = text(values, "request"), json = values.json === true, wait = waitMs(values, ctx);
322
+ if (!id || positionals.length !== 1)
323
+ throw new Error("usage: stop --request <id> <run-id|wid|wid/key> [--json]");
324
+ requestRid(id);
325
+ const raw = positionals[0], cut = raw.indexOf("/"), head = cut < 0 ? raw : raw.slice(0, cut);
326
+ const resolved = raw.includes("@") ? { wid: head } : await widOf(ctx.home, head);
327
+ if ("pending" in resolved)
328
+ return pending(ctx, id, json, `run ${head} has no workflow yet`);
329
+ const normalized = request({ action: "stop", target: raw.includes("@") ? raw : `${resolved.wid}${raw.slice(head.length)}` }, ctx.cwd ?? process.cwd());
330
+ const done = await submitAndWait(ctx, id, "stop", normalized.body, undefined, wait, json);
331
+ if ("code" in done)
332
+ return done.code;
333
+ return decided(ctx, id, done, json);
334
+ }
335
+ function decided(ctx, id, done, json) {
336
+ if (done.outcome.type === "rejected") {
337
+ ctx.write(json ? JSON.stringify({ request: id, applied: false, reason: done.outcome.reason, spec_digest: done.sent.digest }) : `${id}: rejected ${done.outcome.reason}`);
338
+ return EXIT.rejected;
339
+ }
340
+ // A follow-up opens a new generation: the orchestrator records it in the workflow journal under the request's rid.
341
+ const body = done.sent.request.body;
342
+ const wid = typeof body.to === "string" ? body.to.split("/")[0].split("@")[0] : undefined;
343
+ const opened = done.sent.request.kind === "send" && wid && existsSync(journalPath(ctx.home, wid))
344
+ ? readJournalSnapshot(journalPath(ctx.home, wid)).find(e => e.type === "generation" && e.rid === done.sent.request.rid) : undefined;
345
+ const reply = { request: id, applied: true, ...(opened ? { generation: Number(opened.gen), call: `${wid}/${String(opened.key)}` } : {}), spec_digest: done.sent.digest };
346
+ ctx.write(json ? JSON.stringify(reply) : `${id}: applied${opened ? ` (follow-up generation ${reply.generation} of ${reply.call})` : ""}`);
347
+ return EXIT.ok;
348
+ }
@@ -4,10 +4,14 @@
4
4
  // `orchestrator` record has no `restart: true`) would keep the request as an invalid inbox file and never idle-exit, so it
5
5
  // is never sent one: the client checks the journals itself and ends it with SIGTERM (its shutdown fences what still runs,
6
6
  // and recovery resumes it) — a check without the orchestrator's launch gate, so a launch can slip in between.
7
+ import { readFileSync } from "node:fs";
8
+ import { execFileSync } from "node:child_process";
9
+ import { hostname, userInfo } from "node:os";
7
10
  import { setTimeout as delay } from "node:timers/promises";
8
11
  import { readJournalSnapshot } from "../kernel/journal.js";
9
12
  import { journalPath, orchLedger } from "../paths.js";
10
13
  import { JT } from "../types.js";
14
+ import { restartInputError, restartRefusal } from "../orchestrator/restart.js";
11
15
  import { emptyLedger, foldLedger } from "../orchestrator/ledger.js";
12
16
  import { allWorkflows, liveCalls, processAlive } from "../orchestrator/snapshot.js";
13
17
  /** The orchestrator that runs now: the last `orchestrator` record without an exit, whose process lives. */
@@ -15,29 +19,36 @@ export function currentOrchestrator(home) {
15
19
  const o = foldLedger(emptyLedger(), readJournalSnapshot(orchLedger(home))).orchestrator;
16
20
  return o && !o.exited && processAlive(o.pid, o.start) ? { version: o.version, pid: o.pid, ...(o.start ? { start: o.start } : {}), ts: o.ts, ...(o.restart ? { restart: true } : {}) } : undefined;
17
21
  }
18
- /** Calls whose execution runs per the journals: running, asking without having hibernated (its child still runs), or
19
- * running a gate (a gate-intent without its outcome; an interrupted gate is not run again). */
20
- export function journalLiveCalls(home, now = Date.now()) {
21
- const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
22
+ /** Best-effort provenance; never read the live DSA home to identify the invoking process. */
23
+ export function cliInitiator(env) {
24
+ if (env.DSA_CALL)
25
+ return { call: env.DSA_CALL };
26
+ let parent = "";
27
+ try {
28
+ parent = process.platform === "linux" ? readFileSync(`/proc/${process.ppid}/cmdline`, "utf8").replace(/\0/g, " ").trim()
29
+ : process.platform === "darwin" ? execFileSync("ps", ["-o", "command=", "-p", String(process.ppid)], { encoding: "utf8", timeout: 1000, maxBuffer: 64 * 1024 }).trim() : "";
30
+ }
31
+ catch { /* Parent may have exited or /proc may be unavailable. */ }
32
+ return { cli: { user: userInfo().username, host: hostname(), ppid: process.ppid, parent: parent.slice(0, 200) } };
33
+ }
34
+ /** Calls whose execution runs per the journals: running, asking without having hibernated, or an unresolved gate. */
35
+ export function journalLiveCalls(home) {
22
36
  return allWorkflows(home).flatMap(wf => {
23
37
  const entries = readJournalSnapshot(journalPath(home, wf.wid));
24
38
  const done = new Set(entries.filter(e => e.type === "gate").map(e => String(e.id)));
25
39
  const sealed = new Set(entries.filter(e => e.type === JT.sealed).map(e => String(e.call)));
26
- const gates = new Map(entries.filter(e => e.type === "gate-intent" && !done.has(String(e.id)) && !sealed.has(String(e.call))).map(e => [String(e.call), e.ts]));
27
- const where = wf.origin ? ` from ${wf.origin}` : "";
28
- const running = liveCalls(wf).filter(c => !gates.has(c.callId) && (c.phase === "running" || (c.phase === "asking" && c.exec !== undefined && !c.hibernated)))
29
- .map(c => `${wf.wid}/${c.key}${c.startedAt ? ` ${age(now - c.startedAt)}` : ""}${where}`);
30
- const gating = [...gates].map(([call, ts]) => `${wf.wid}/${call.slice(call.indexOf("/") + 1, call.lastIndexOf("@"))} gate ${age(now - ts)}${where}`);
40
+ const gates = new Map(entries.filter(e => e.type === "gate-intent" && !done.has(String(e.id)) && !sealed.has(String(e.call))).map(e => [String(e.call), e]));
41
+ const running = liveCalls(wf).filter(c => !gates.has(c.callId) && c.exec !== undefined && (c.phase === "running" || (c.phase === "asking" && !c.hibernated)))
42
+ .map(c => ({ wid: wf.wid, key: c.key, gen: c.gen, callId: c.callId, exec: c.exec, since: c.startedAt ?? wf.startedAt ?? Date.now(), phase: "child", origin: wf.origin }));
43
+ const gating = [...gates].map(([call, e]) => ({ wid: wf.wid, key: call.slice(call.indexOf("/") + 1, call.lastIndexOf("@")), gen: Number(call.slice(call.lastIndexOf("@") + 1)), callId: call, exec: String(e.id), since: e.ts, phase: "gate", origin: wf.origin }));
31
44
  return [...running, ...gating];
32
45
  });
33
46
  }
34
- /** For an orchestrator without the restart request kind: refuse while calls run (unless forced), else SIGTERM it. */
35
- export function legacyRestart(home, old, force) {
36
- const live = journalLiveCalls(home);
37
- if (live.length && !force) {
38
- const shown = live.slice(0, 8).join("; ") + (live.length > 8 ? `; +${live.length - 8} more` : "");
39
- return { applied: false, reason: `busy: ${live.length} running call${live.length === 1 ? "" : "s"} on orchestrator ${old.version} (pid ${old.pid}): ${shown} — retry when they finish (or drain first), or force to fence them; they resume on the new orchestrator` };
40
- }
47
+ /** Legacy has no launch gate: check immediately before SIGTERM; a launch can still slip between (documented). */
48
+ export function legacyRestart(home, old, body, options = {}) {
49
+ const reason = restartInputError(body, options.subagent) ?? restartRefusal(home, journalLiveCalls(home), body, options.tool);
50
+ if (reason)
51
+ return { applied: false, reason };
41
52
  try {
42
53
  process.kill(old.pid, "SIGTERM");
43
54
  }
@@ -48,6 +59,13 @@ export function legacyRestart(home, old, force) {
48
59
  return { applied: true };
49
60
  }
50
61
  /** Wait (bounded) until the process ends; false when it still runs at the deadline. */
62
+ /** The orchestrator that decided request `rid`: the last start recorded before its resolution. */
63
+ export function decidedBy(home, rid) {
64
+ const entries = readJournalSnapshot(orchLedger(home));
65
+ const at = entries.findIndex(e => (e.type === JT.applied || e.type === JT.rejected) && e.rid === rid);
66
+ const o = at < 0 ? undefined : entries.slice(0, at).findLast(e => e.type === 'orchestrator');
67
+ return o ? { version: String(o.version), pid: Number(o.pid), ...(o.start ? { start: String(o.start) } : {}), ts: o.ts, ...(o.restart ? { restart: true } : {}) } : undefined;
68
+ }
51
69
  export async function waitExit(old, timeoutMs) {
52
70
  const deadline = performance.now() + timeoutMs;
53
71
  while (processAlive(old.pid, old.start)) {
@@ -35,7 +35,8 @@ export function planDecisions(records, candidates, decide) {
35
35
  if (bound) {
36
36
  if (bound.hash === hash)
37
37
  envelopes.set(req.rid, req);
38
- else if (!conflicts.has(req.rid)) {
38
+ // A1, R1: a conflicting duplicate of a resolved rid is only dropped (its file still goes); it never records a decision.
39
+ else if (!view.resolved.has(req.rid) && !conflicts.has(req.rid)) {
39
40
  conflicts.add(req.rid);
40
41
  emit({ type: 'rejected', rid: req.rid, reason: 'identity-conflict' });
41
42
  }