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.
- package/CHANGELOG.md +48 -0
- package/README.md +111 -12
- package/dist/agent/main/tool.js +26 -4
- package/dist/agent/main.js +36 -16
- package/dist/cli/control.js +43 -20
- package/dist/cli/main.js +54 -17
- package/dist/cli/requests.js +348 -0
- package/dist/cli/restart.js +34 -16
- package/dist/kernel/lifecycle.js +2 -1
- package/dist/orchestrator/engine.js +34 -20
- package/dist/orchestrator/executor/index.js +42 -0
- package/dist/orchestrator/ledger.js +9 -3
- package/dist/orchestrator/restart.js +57 -0
- package/dist/orchestrator/snapshot.js +7 -4
- package/dist/orchestrator/store.js +89 -11
- package/dist/requests.js +87 -0
- package/package.json +1 -1
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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,
|
|
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, {
|
|
140
|
-
|
|
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(`
|
|
143
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/cli/restart.js
CHANGED
|
@@ -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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
/**
|
|
35
|
-
export function legacyRestart(home, old,
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
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)) {
|
package/dist/kernel/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|