pi-durable-subagents 1.0.16 → 1.0.18

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
@@ -12,7 +12,9 @@ import { resolution, start, startOrchestrator, submit } from "./control.js";
12
12
  import { doctor, renderDoctor, size } from "./doctor.js";
13
13
  import { smoke } from "./smoke.js";
14
14
  import { serviceFiles, manageService } from "./service.js";
15
- const commands = ["smoke", "tail", "status", "events", "start", "resume", "drain", "stop", "stop-all", "prune", "doctor", "install-service", "uninstall-service", "help"];
15
+ import { leaseLines, leaseState } from "../platform/lease.js";
16
+ import { currentOrchestrator, legacyRestart, waitExit, waitSuccessor } from "./restart.js";
17
+ const commands = ["smoke", "tail", "status", "events", "start", "resume", "drain", "stop", "stop-all", "prune", "restart", "leases", "doctor", "install-service", "uninstall-service", "help"];
16
18
  /** P25: Reject ambiguous CLI arguments before any durable action. */
17
19
  export function parseArgs(args) {
18
20
  if (!args.length || (args.length === 1 && ["-h", "--help"].includes(args[0])))
@@ -31,11 +33,17 @@ export function parseArgs(args) {
31
33
  olderThanDays = Number(value);
32
34
  rest = [...rest.slice(0, at), ...rest.slice(at + 2)];
33
35
  }
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");
34
42
  const json = rest.includes("--json"), dryRun = rest.includes("--dry-run");
35
43
  if (dryRun && !["install-service", "uninstall-service"].includes(command))
36
44
  throw new Error("--dry-run requires a service command");
37
- if (json && !["status", "events", "tail", "doctor"].includes(command))
38
- throw new Error("--json is only supported by status, events, tail and doctor");
45
+ if (json && !["status", "events", "tail", "doctor", "leases"].includes(command))
46
+ throw new Error("--json is only supported by status, events, tail, leases and doctor");
39
47
  if (rest.filter(a => a === "--json").length > 1 || rest.filter(a => a === "--dry-run").length > 1 || rest.some(a => a.startsWith("-") && a !== "--json" && a !== "--dry-run"))
40
48
  throw new Error("Unknown or repeated option");
41
49
  const targets = rest.filter(a => a !== "--json" && a !== "--dry-run");
@@ -48,7 +56,7 @@ export function parseArgs(args) {
48
56
  throw new Error("Invalid workflow id");
49
57
  if (target && olderThanDays !== undefined)
50
58
  throw new Error("prune takes a workflow id or --older-than, not both");
51
- return { command, json, ...(dryRun ? { dryRun } : {}), ...(target ? { target } : {}), ...(olderThanDays !== undefined ? { olderThanDays } : {}) };
59
+ return { command, json, ...(force ? { force } : {}), ...(dryRun ? { dryRun } : {}), ...(target ? { target } : {}), ...(olderThanDays !== undefined ? { olderThanDays } : {}) };
52
60
  }
53
61
  const clip = (text, n) => text.length > n ? `${text.slice(0, n)}…` : text;
54
62
  /** P25, T10: Render journal-derived status (one line per call, last output line only) without live orchestrator memory. */
@@ -57,7 +65,7 @@ export function renderStatus(wf) {
57
65
  return [`${wf.wid}@${wf.rev}${wf.name ? ` ${wf.name}` : ""}: ${wf.status}${wf.error ? ` (${clip(wf.error, 300)})` : ""} · ${sealed}/${total} sealed${used ? ` · ${formatUsage(wf.usage)}` : ""}`,
58
66
  ...wf.calls.map(c => {
59
67
  const last = c.result?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1), err = c.result?.error;
60
- return ` ${c.key}@${c.gen} ${c.result?.status ?? c.phase}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage && (c.usage.input || c.usage.output) ? ` ${formatUsage(c.usage)}` : ""}${last ? ` ${JSON.stringify(clip(last, 160))}` : err ? ` (${clip(err, 160)})` : ""}${c.sharedWorktree ? ` (shares worktree with ${c.sharedWorktree.join(", ")})` : ""}`;
68
+ return ` ${c.key}@${c.gen} ${c.result?.status ?? c.phase}${c.model ? ` ${c.model}` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage && (c.usage.input || c.usage.output) ? ` ${formatUsage(c.usage)}` : ""}${last ? ` ${JSON.stringify(clip(last, 160))}` : err ? ` (${clip(err, 160)})` : ""}${c.sharedWorktree ? ` (shares worktree with ${c.sharedWorktree.join(", ")})` : ""}${c.writerWait && !c.result && c.phase !== "sealed" ? ` (waiting for writer lock: ${c.writerWait.root} held by ${c.writerWait.holder})` : ""}`;
61
69
  }),
62
70
  ...wf.attention.map(a => ` ${a.kind}: ${JSON.stringify(clip(a.text, 300))}`),
63
71
  ...(wf.scriptLog ? [` script log: ${wf.scriptLog}`] : [])].join("\n");
@@ -65,13 +73,13 @@ export function renderStatus(wf) {
65
73
  /** P25, T10: Render the compact status projection shared with the `subagents` tool. */
66
74
  export function renderView(view) {
67
75
  const lines = view.workflows.map(w => [`${w.wid}@${w.rev}${w.name ? ` ${w.name}` : ""}: ${w.status}${w.followUps ? " (follow-up running)" : ""}${w.error ? ` (${clip(w.error, 200)})` : ""} · ${w.done}/${w.planned ?? w.calls.length}${w.planned === undefined && w.status === "running" ? "+" : ""} done${w.usage.input || w.usage.output || w.usage.costUsd ? ` · ${formatUsage(w.usage)}` : ""}`,
68
- ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${c.model ? ` ${c.model}` : ""}${c.switching ? ` → ${c.switching} (requested)` : ""}${c.switchFailed ? ` (switch refused: ${c.switchFailed})` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}${c.sharedWorktree ? ` (shares worktree with ${c.sharedWorktree.join(", ")})` : ""}`),
76
+ ...w.calls.map(c => ` ${c.key}@${c.gen} ${c.status ?? c.phase}${c.hibernated ? " (hibernated, no slot)" : ""}${c.model ? ` ${c.model}` : ""}${c.switching ? ` → ${c.switching} (requested)` : ""}${c.switchFailed ? ` (switch refused: ${c.switchFailed})` : ""}${c.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}${c.sharedWorktree ? ` (shares worktree with ${c.sharedWorktree.join(", ")})` : ""}${c.writerWait && c.phase !== "sealed" ? ` (waiting for writer lock: ${c.writerWait.root} held by ${c.writerWait.holder})` : ""}${c.lease && c.phase !== "sealed" ? ` (${c.lease})` : ""}`),
69
77
  ...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
70
78
  if (view.paused)
71
79
  lines.unshift(`${view.paused} (pi-durable-subagents resume)`);
72
80
  if (view.olderFinished)
73
81
  lines.push(`(+${view.olderFinished} older finished workflows; status <wid> shows one in detail)`);
74
- const footer = [view.slots?.length ? `slots: ${view.slots.join(", ")}` : "", view.config ? `config: ${view.config}` : "", view.configRejected ? `config.json rejected: ${view.configRejected}` : "", view.orchestrator ? `orchestrator: ${view.orchestrator}` : "", ...(view.exhausted ?? []), view.versionNote ? `note: ${view.versionNote}` : ""].filter(Boolean);
82
+ const footer = [view.slots?.length ? `slots: ${view.slots.join(", ")}` : "", view.config ? `config: ${view.config}` : "", view.configRejected ? `config.json rejected: ${view.configRejected}` : "", view.orchestrator ? `orchestrator: ${view.orchestrator}` : "", ...(view.leases ?? []).map(l => `lease: ${l}`), ...(view.exhausted ?? []), view.versionNote ? `note: ${view.versionNote}` : ""].filter(Boolean);
75
83
  if (!lines.length)
76
84
  lines.push("No workflows");
77
85
  return [...lines, ...footer].join("\n");
@@ -110,12 +118,53 @@ export function serviceEntryError(entry) {
110
118
  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.`;
111
119
  return undefined;
112
120
  }
113
- 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>] | doctor [--json] | install-service [--dry-run] | uninstall-service [--dry-run] | chaos [--scenario <1-9>] [--keep] [--json]";
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]";
122
+ /** Restart: the orchestrator exits when no execution runs (or `force`) and the installed version takes over. */
123
+ async function restartCommand(home, env, write, options) {
124
+ const starter = options.starter ?? startOrchestrator, old = currentOrchestrator(home);
125
+ if (!old) {
126
+ 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
+ return 0;
128
+ }
129
+ const waitMs = options.waitMs ?? 30_000;
130
+ if (!old.restart) {
131
+ // An orchestrator older than the restart request: check its journals here and end it with SIGTERM.
132
+ const legacy = legacyRestart(home, old, options.force);
133
+ if (!legacy.applied) {
134
+ write(`restart refused: ${legacy.reason}`);
135
+ return 1;
136
+ }
137
+ }
138
+ else {
139
+ const [req] = await submit(home, "restart", undefined, env, { force: options.force });
140
+ const outcome = await resolution(home, req.rid, waitMs);
141
+ if (!outcome) {
142
+ write(`submitted restart ${req.rid}; not resolved within ${Math.round(waitMs / 1000)} s (see: pi-durable-subagents doctor)`);
143
+ return 1;
144
+ }
145
+ if (outcome.type === "rejected") {
146
+ write(`restart refused: ${outcome.reason}`);
147
+ return 1;
148
+ }
149
+ }
150
+ if (!await waitExit(old, Math.max(waitMs, 60_000))) {
151
+ write(`restart: orchestrator ${old.version} (pid ${old.pid}) is still shutting down; see: pi-durable-subagents status`);
152
+ return 1;
153
+ }
154
+ await starter(home, env);
155
+ const next = await waitSuccessor(home, old, 15_000);
156
+ write(next ? `restarted: orchestrator ${old.version} (pid ${old.pid}) → ${next.version} (pid ${next.pid})` : `restart: orchestrator ${old.version} (pid ${old.pid}) exited; the next one starts when work is pending`);
157
+ return 0;
158
+ }
114
159
  /** P1, P21, P25, P38: Dispatch the public CLI using durable requests and read-only snapshots. */
115
160
  export async function main(args = process.argv.slice(2), options = {}) {
161
+ if (args[0] === "hold") {
162
+ const { hold, parseHold } = await import("./hold.js");
163
+ return hold(parseHold(args.slice(1)), { env: options.env ?? process.env });
164
+ }
116
165
  if (args[0] === "chaos")
117
166
  return (await import("./chaos/index.js")).chaos(args.slice(1), options.env ?? process.env, options.write);
118
- const { command, target, json, dryRun, olderThanDays } = parseArgs(args), env = options.env ?? process.env;
167
+ const { command, target, json, dryRun, olderThanDays, force } = parseArgs(args), env = options.env ?? process.env;
119
168
  const home = dsaHome(env), write = options.write ?? (line => console.log(line));
120
169
  if (command === "help") {
121
170
  write(HELP);
@@ -201,6 +250,13 @@ export async function main(args = process.argv.slice(2), options = {}) {
201
250
  write(` ${String(e.wid)}`);
202
251
  return 0;
203
252
  }
253
+ if (command === "leases") {
254
+ const state = leaseState(home);
255
+ write(json ? JSON.stringify(state, null, 2) : state.length ? leaseLines(state).join("\n") : "No leases held or waited for");
256
+ return 0;
257
+ }
258
+ if (command === "restart")
259
+ return restartCommand(home, env, write, { force: force === true, starter: options.starter, waitMs: options.waitMs });
204
260
  for (const req of await submit(home, command, target, env))
205
261
  write(`submitted ${req.kind} ${req.rid}`);
206
262
  return 0;
@@ -0,0 +1,71 @@
1
+ // Restart: exit the running orchestrator so the installed version takes over, without interrupting running calls.
2
+ // The orchestrator decides (request kind "restart", RestartBody): it stops launching executions, refuses while any runs
3
+ // (unless forced), and otherwise exits and starts its successor. An orchestrator older than this request kind (its
4
+ // `orchestrator` record has no `restart: true`) would keep the request as an invalid inbox file and never idle-exit, so it
5
+ // is never sent one: the client checks the journals itself and ends it with SIGTERM (its shutdown fences what still runs,
6
+ // and recovery resumes it) — a check without the orchestrator's launch gate, so a launch can slip in between.
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { readJournalSnapshot } from "../kernel/journal.js";
9
+ import { journalPath, orchLedger } from "../paths.js";
10
+ import { JT } from "../types.js";
11
+ import { emptyLedger, foldLedger } from "../orchestrator/ledger.js";
12
+ import { allWorkflows, liveCalls, processAlive } from "../orchestrator/snapshot.js";
13
+ /** The orchestrator that runs now: the last `orchestrator` record without an exit, whose process lives. */
14
+ export function currentOrchestrator(home) {
15
+ const o = foldLedger(emptyLedger(), readJournalSnapshot(orchLedger(home))).orchestrator;
16
+ 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
+ }
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
+ return allWorkflows(home).flatMap(wf => {
23
+ const entries = readJournalSnapshot(journalPath(home, wf.wid));
24
+ const done = new Set(entries.filter(e => e.type === "gate").map(e => String(e.id)));
25
+ 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}`);
31
+ return [...running, ...gating];
32
+ });
33
+ }
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
+ }
41
+ try {
42
+ process.kill(old.pid, "SIGTERM");
43
+ }
44
+ catch (error) {
45
+ if (error.code !== "ESRCH")
46
+ throw error;
47
+ }
48
+ return { applied: true };
49
+ }
50
+ /** Wait (bounded) until the process ends; false when it still runs at the deadline. */
51
+ export async function waitExit(old, timeoutMs) {
52
+ const deadline = performance.now() + timeoutMs;
53
+ while (processAlive(old.pid, old.start)) {
54
+ if (performance.now() >= deadline)
55
+ return false;
56
+ await delay(100);
57
+ }
58
+ return true;
59
+ }
60
+ /** Wait (bounded) for an orchestrator other than `old` to record its start; undefined at the deadline. */
61
+ export async function waitSuccessor(home, old, timeoutMs) {
62
+ const deadline = performance.now() + timeoutMs;
63
+ for (;;) {
64
+ const now = currentOrchestrator(home);
65
+ if (now && (!old || now.pid !== old.pid || now.ts !== old.ts))
66
+ return now;
67
+ if (performance.now() >= deadline)
68
+ return undefined;
69
+ await delay(100);
70
+ }
71
+ }
@@ -1,5 +1,5 @@
1
1
  import { schemaProblems } from "../agent/child/schema.js";
2
- const fields = new Set(["agent", "task", "model", "cwd", "timeoutMs", "output", "schema", "gate", "isolation", "context", "budget", "once", "tools", "skills", "key"]);
2
+ const fields = new Set(["agent", "task", "model", "cwd", "timeoutMs", "output", "schema", "gate", "isolation", "context", "budget", "once", "tools", "skills", "key", "writer"]);
3
3
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
4
  const positive = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
5
5
  const text = (v) => typeof v === "string" && v.trim() !== "";
@@ -67,6 +67,8 @@ export function validateCallSpec(spec, options = {}) {
67
67
  errors.push(...budget(s.budget));
68
68
  if (has("once") && typeof s.once !== "boolean")
69
69
  errors.push(`once must be a boolean${got(s.once)}`);
70
+ if (has("writer") && typeof s.writer !== "boolean")
71
+ errors.push(`writer must be a boolean${got(s.writer)}`);
70
72
  for (const k of ["tools", "skills"])
71
73
  if (has(k) && (!Array.isArray(s[k]) || !s[k].every(v => typeof v === "string")))
72
74
  errors.push(`${k} must be an array of strings${got(s[k])}`);
@@ -50,7 +50,7 @@ function isRequest(value) {
50
50
  const r = value;
51
51
  // A3, C11: request identities are short and path-safe, so every derived path (staging, receipts) is valid.
52
52
  return typeof r.rid === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(r.rid) && typeof r.from === 'string' && typeof r.to === 'string' && Number.isSafeInteger(r.sseq) && r.sseq > 0 &&
53
- ['run', 'send', 'stop', 'revise', 'resume', 'drain', 'prune', 'task', 'steer', 'follow-up', 'answer', 'model', 'continue', 'withdraw', 'call', 'emit'].includes(r.kind) && Object.hasOwn(r, 'body');
53
+ ['run', 'send', 'stop', 'revise', 'resume', 'drain', 'prune', 'restart', 'task', 'steer', 'follow-up', 'answer', 'model', 'continue', 'withdraw', 'call', 'emit'].includes(r.kind) && Object.hasOwn(r, 'body');
54
54
  }
55
55
  /** P3: Scan immutable requests, reporting invalid files without admitting them. */
56
56
  export async function scanInbox(inboxDir, report = (path, error) => console.warn(path, error)) {
@@ -8,7 +8,7 @@ import { readFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
  import { contentHash } from "../kernel/ids.js";
10
10
  /** The keys the orchestrator reads; config.json also holds pi-side settings (ui, onQuit) that it ignores. */
11
- const KEYS = ["defaultModel", "pools", "providers", "memory", "k"];
11
+ const KEYS = ["defaultModel", "pools", "providers", "memory", "writerLock", "k"];
12
12
  const K = ["lossBound", "checkpointMs", "stallMs", "progressMs", "switchTimeoutMs", "idleExitMs", "trackerMs", "hibernateMs", "spawnBudget", "probeMs"];
13
13
  export const configPath = (home) => join(home, "config.json");
14
14
  /** The orchestrator's part of a parsed config.json. */
@@ -23,7 +23,9 @@ const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
23
23
  export function configProblem(raw) {
24
24
  if (!record(raw))
25
25
  return "config.json must be a JSON object";
26
- const { defaultModel, pools, providers, memory, k } = raw;
26
+ const { defaultModel, pools, providers, memory, writerLock, k } = raw;
27
+ if (writerLock !== undefined && writerLock !== "queue" && writerLock !== "off")
28
+ return 'writerLock must be "queue" or "off"';
27
29
  if (defaultModel !== undefined && typeof defaultModel !== "string")
28
30
  return "defaultModel must be a string";
29
31
  if (pools !== undefined) {
@@ -91,6 +91,7 @@ export class Engine {
91
91
  queue = Promise.resolve();
92
92
  failure;
93
93
  closed = false;
94
+ restarting = false;
94
95
  generations = new Set();
95
96
  // wid -> executor runs whose follow-up has not run yet (prune never closes a journal they may still append to).
96
97
  running = new Map();
@@ -442,12 +443,36 @@ export class Engine {
442
443
  st.running.clear();
443
444
  }
444
445
  }
446
+ else if (req.kind === 'restart') {
447
+ // A replay (the restart was recorded, then the process ended before its resolution) applies without restarting again.
448
+ if (!this.ledgers.orch.entries().some(e => e.type === 'restart' && e.rid === req.rid)) {
449
+ const force = req.body?.force === true;
450
+ const gate = this.executor.quiesce?.() ?? { live: [], resume() { } };
451
+ if (gate.live.length && !force) {
452
+ gate.resume();
453
+ return { action: 'reject', reason: this.busyReason(gate.live) };
454
+ }
455
+ await this.ledgers.orch.append('restart', { rid: req.rid, force, live: gate.live.map(l => l.exec) });
456
+ this.restarting = true;
457
+ }
458
+ }
445
459
  else if (req.kind === 'prune')
446
460
  return this.prune(req);
447
461
  else
448
462
  return { action: 'reject', reason: 'unsupported-kind' };
449
463
  return { action: 'apply' };
450
464
  }
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
+ /** Set by an applied restart: the loop ends and the process exits; its successor recovers every workflow. */
475
+ get restartRequested() { return this.restarting; }
451
476
  /** A1, housekeeping: Prune finished workflows (named, or all ended more than olderThanDays ago); a replay of a
452
477
  * committed prune is applied again and continues with the workflows still eligible. */
453
478
  async prune(req) {
@@ -729,7 +754,7 @@ export class Engine {
729
754
  this.poll = setInterval(() => this.background(() => this.consume()), 1000);
730
755
  let idleSince = performance.now();
731
756
  try {
732
- while (!signal?.aborted && !this.closed) {
757
+ while (!signal?.aborted && !this.closed && !this.restarting) {
733
758
  await this.queue;
734
759
  if (this.failure)
735
760
  throw this.failure;