pi-durable-subagents 1.0.21 → 1.0.23

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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.23
4
+
5
+ - An asker cut off by a restart (also `restart --force`) now hibernates and
6
+ resumes with the answer, as documented. A graceful shutdown let its `ask`
7
+ end with its own "Session shut down" (or "Ask aborted") error before the
8
+ process exited; recovery took that for an answered ask, recorded a loss and
9
+ ran the call again, so the model asked a second time, `describe` showed a
10
+ `lastFence` and the stale question stayed listed. Only a killed process (no
11
+ result at all) was recognised before.
12
+
13
+ ## 1.0.22
14
+
15
+ - The installed CLI runs `run`, `send` and `stop` again: 1.0.21 loaded the
16
+ optional pi peer package from the tool schema and failed with
17
+ `ERR_MODULE_NOT_FOUND` where it does not resolve (pi's own npm prefix,
18
+ `npm i -g`). The schema lives apart now; the import check rejects any pi
19
+ import reachable from the CLI, orchestrator or evaluator, and `pack:smoke`
20
+ drives a run by request id from a package directory that cannot see pi.
21
+ - `describe` reports `lastFence` only for an execution that was cut off; an
22
+ execution that ended its turn, hibernated, or was stopped, timed out or
23
+ over budget, or hibernated on its question (also when recovery finds only its
24
+ `ask` was running) is not an interruption. A `once` call sealed `unknown` and a call
25
+ sealed after repeated losses still name their fence.
26
+ - With `--json`, a `run`/`send`/`stop` refused before submission (unknown
27
+ agent, invalid spec, usage) answers `{request, applied: false, reason,
28
+ spec_digest?}` instead of plain text; exit code 1 as before. A failure after
29
+ submission, and a retry of a recorded run whose agent has since gone, are
30
+ pending (75), never a refusal; other content under a recorded id is a
31
+ `request-conflict` (3) before any agent check.
32
+ - The import check parses with TypeScript, follows the executor, which the
33
+ orchestrator loads through `import(new URL(…))`, and treats only
34
+ `import type` as erased (`import { type X }` still loads the module).
35
+
3
36
  ## 1.0.21
4
37
 
5
38
  1.0.20 was not published; its changes ship in this release.
package/README.md CHANGED
@@ -286,9 +286,9 @@ session and is rejected.
286
286
  | exit | meaning | `--json` reply |
287
287
  | --- | --- | --- |
288
288
  | 0 | decided and applied (a retry gets the same answer) | run: `{request, wid, created, spec_digest}`; send/stop: `{request, applied, generation?, call?, spec_digest}` |
289
- | 1 | decided and rejected (`reason`), or a usage error | `{request, applied: false, reason, spec_digest}` |
289
+ | 1 | decided and rejected (`reason`), or refused before submission (usage error, invalid spec, unknown agent: this invocation submitted nothing) | `{request, applied: false, reason, spec_digest?}` (`spec_digest` once the content could be hashed) |
290
290
  | 3 | `request-conflict`: the id already names other content; nothing was sent | `{request, error, wid?, spec_digest, state}` (the original's digest and state) |
291
- | 75 | not decided yet: submitted but undecided in `--wait-ms` (default 60 s), submitted and then a later step failed (`reason` says which), or a lock was busy (`reason: "busy"`); retry with the same id | `{request, pending: true, reason?}` |
291
+ | 75 | not decided yet: submitted but undecided in `--wait-ms` (default 60 s), submitted and then a later step failed (`reason` says which), or a lock was busy (`reason: "busy"`); an id already recorded is never refused again (the same content gets its first outcome, its agents are not rechecked; other content is a conflict); retry with the same id | `{request, pending: true, reason?}` |
292
292
 
293
293
  `created` is false when the id had already been decided before the command
294
294
  ran. A conflicting id stays conflicting forever, also after `prune`: the
@@ -311,9 +311,13 @@ full text, `qid`, `rev` and the `to` address to answer), `sealed` (finished:
311
311
  `pruned` (`pruned: {status, endedAt}`; workflows pruned before 1.0.21 have
312
312
  only `endedAt`), with `wid`, `request` and
313
313
  `spec_digest`. Live calls also show what they wait for (slot, writer lock,
314
- lease, exhausted provider) and the last fence of their execution
315
- (`lastFence: {at, exec, reason}`); the reason is a best-effort reading of the
316
- journals: `restart-force` when a forced restart listed the execution,
314
+ lease, exhausted provider). `lastFence: {at, exec, reason}` appears only
315
+ when an execution was cut off: it had not ended its turn when it was fenced,
316
+ did not hibernate on a question (also when recovery finds it cut off while only
317
+ its question's `ask` ran), and was not ended on purpose (stop, timeout,
318
+ budget). Every execution ends with a fence, so an ordinary end is not
319
+ reported; a `once` call sealed `unknown` or a call sealed after repeated losses
320
+ is. The reason is a best-effort reading of the journals: `restart-force` when a forced restart listed the execution,
317
321
  `orchestrator-crash` when the orchestrator died uncleanly while it ran, else
318
322
  `process-died`.
319
323
 
@@ -1,7 +1,7 @@
1
1
  import { watch } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { Type } from '@earendil-works/pi-ai';
4
- import { CT, ENV, JT } from "../types.js";
4
+ import { ASK_CUT, CT, ENV, JT } from "../types.js";
5
5
  import { readJournalSnapshot } from "../kernel/journal.js";
6
6
  import { scanInbox } from "../kernel/mailbox.js";
7
7
  import { contentHash } from "../kernel/ids.js";
@@ -250,7 +250,7 @@ export function registerChild(pi) {
250
250
  pi.on('session_shutdown', async () => {
251
251
  active = false;
252
252
  watcher?.close();
253
- blocked?.reject(new Error('Session shut down'));
253
+ blocked?.reject(new Error(ASK_CUT.shutdown));
254
254
  blocked = undefined;
255
255
  await queue;
256
256
  });
@@ -263,12 +263,12 @@ export function registerChild(pi) {
263
263
  // Attach immediately so abort during the queued intake never produces an unhandled rejection.
264
264
  void result.catch(() => { });
265
265
  const abort = () => { void serial(async () => { if (blocked?.resolve === resolve)
266
- blocked = undefined; reject(new Error('Ask aborted')); }); };
266
+ blocked = undefined; reject(new Error(ASK_CUT.aborted)); }); };
267
267
  signal?.addEventListener('abort', abort, { once: true });
268
268
  try {
269
269
  await serial(async () => {
270
270
  if (!active || signal?.aborted)
271
- throw new Error('Ask aborted');
271
+ throw new Error(ASK_CUT.aborted);
272
272
  if (blocked)
273
273
  throw new Error('Another question is already blocked');
274
274
  const qid = contentHash({ exec, question }), rev = (state.questions.get(qid)?.rev ?? 0) + 1;
@@ -0,0 +1,25 @@
1
+ // The subagents tool's parameter schema. Kept apart from tool.ts because it needs the optional pi peer
2
+ // package, which the CLI (sharing tool.ts) must not import: it runs from the package directory.
3
+ import { Type } from "@earendil-works/pi-ai";
4
+ const stepsDoc = "Call specs {agent, task, model?, cwd?, timeoutMs?, output?, schema?, gate?, isolation?, context?, budget?, once?, tools?, skills?, writer?, key?}; " +
5
+ "each call is addressed as '<wid>/<key>', where key is the step's own unique key or else 'tasks:<i>' / 'chain:<i>'.";
6
+ export const parameters = Type.Object({
7
+ action: Type.Optional(Type.Union(["run", "agents", "send", "stop", "revise", "status", "resume", "drain", "restart"].map(v => Type.Literal(v)))),
8
+ workflow: Type.Optional(Type.String()), source: Type.Optional(Type.String()), args: Type.Optional(Type.Unknown()),
9
+ tasks: Type.Optional(Type.Array(Type.Any(), { description: `Parallel calls. ${stepsDoc}` })),
10
+ chain: Type.Optional(Type.Array(Type.Any(), { description: `Sequential calls ({previous} = previous output). ${stepsDoc}` })),
11
+ agent: Type.Optional(Type.String()), task: Type.Optional(Type.String()), model: Type.Optional(Type.String()),
12
+ cwd: Type.Optional(Type.String({ description: "Run directory (default: this session's). Relative workflow, inputs and call cwd paths resolve against it." })),
13
+ to: Type.Optional(Type.String()), kind: Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("follow-up"), Type.Literal("answer"), Type.Literal("model")])),
14
+ message: Type.Optional(Type.String()), qid: Type.Optional(Type.String()), rev: Type.Optional(Type.Integer({ minimum: 1 })),
15
+ replaces: Type.Optional(Type.Array(Type.String())), target: Type.Optional(Type.String()), wid: Type.Optional(Type.String()),
16
+ usageBudget: Type.Optional(Type.Object({ tokens: Type.Optional(Type.Number()), costUsd: Type.Optional(Type.Number()) })),
17
+ maxCalls: Type.Optional(Type.Integer({ minimum: 1 })), inputs: Type.Optional(Type.Record(Type.String(), Type.String())),
18
+ name: Type.Optional(Type.String()),
19
+ timeoutMs: Type.Optional(Type.Number({ description: "Per-call limit on active time in milliseconds (a number). Omit unless a hard limit is needed; prefer budgets." })),
20
+ key: Type.Optional(Type.String({ description: "A single agent/task run: the call's key. status with wid: that call's full result." })),
21
+ full: Type.Optional(Type.Boolean({ description: "status: with wid, the complete workflow detail including every output." })),
22
+ force: Type.Optional(Type.Union([Type.String(), Type.Boolean()], { description: "restart: the token shown by a refusal. Show the user the list and obtain explicit approval first; boolean true is refused. Subagents cannot force a restart." })),
23
+ reason: Type.Optional(Type.String({ description: "restart: non-empty reason, at most 500 characters; required with force." })),
24
+ request: Type.Optional(Type.String({ description: "run/send/stop: your own request id (1-124 chars [A-Za-z0-9][A-Za-z0-9._:-]*) making a retry safe: the same id with the same content gets the first outcome; other content is refused (request-conflict)." })),
25
+ }, { additionalProperties: true });
@@ -1,30 +1,7 @@
1
1
  import { restartInputError } from "../../orchestrator/restart.js";
2
2
  import { resolve } from "node:path";
3
- import { Type } from "@earendil-works/pi-ai";
4
3
  import { validateCallSpec } from "../../compat/spec.js";
5
4
  import { compileFanout } from "../../compat/fanout.js";
6
- const stepsDoc = "Call specs {agent, task, model?, cwd?, timeoutMs?, output?, schema?, gate?, isolation?, context?, budget?, once?, tools?, skills?, writer?, key?}; " +
7
- "each call is addressed as '<wid>/<key>', where key is the step's own unique key or else 'tasks:<i>' / 'chain:<i>'.";
8
- export const parameters = Type.Object({
9
- action: Type.Optional(Type.Union(["run", "agents", "send", "stop", "revise", "status", "resume", "drain", "restart"].map(v => Type.Literal(v)))),
10
- workflow: Type.Optional(Type.String()), source: Type.Optional(Type.String()), args: Type.Optional(Type.Unknown()),
11
- tasks: Type.Optional(Type.Array(Type.Any(), { description: `Parallel calls. ${stepsDoc}` })),
12
- chain: Type.Optional(Type.Array(Type.Any(), { description: `Sequential calls ({previous} = previous output). ${stepsDoc}` })),
13
- agent: Type.Optional(Type.String()), task: Type.Optional(Type.String()), model: Type.Optional(Type.String()),
14
- cwd: Type.Optional(Type.String({ description: "Run directory (default: this session's). Relative workflow, inputs and call cwd paths resolve against it." })),
15
- to: Type.Optional(Type.String()), kind: Type.Optional(Type.Union([Type.Literal("steer"), Type.Literal("follow-up"), Type.Literal("answer"), Type.Literal("model")])),
16
- message: Type.Optional(Type.String()), qid: Type.Optional(Type.String()), rev: Type.Optional(Type.Integer({ minimum: 1 })),
17
- replaces: Type.Optional(Type.Array(Type.String())), target: Type.Optional(Type.String()), wid: Type.Optional(Type.String()),
18
- usageBudget: Type.Optional(Type.Object({ tokens: Type.Optional(Type.Number()), costUsd: Type.Optional(Type.Number()) })),
19
- maxCalls: Type.Optional(Type.Integer({ minimum: 1 })), inputs: Type.Optional(Type.Record(Type.String(), Type.String())),
20
- name: Type.Optional(Type.String()),
21
- timeoutMs: Type.Optional(Type.Number({ description: "Per-call limit on active time in milliseconds (a number). Omit unless a hard limit is needed; prefer budgets." })),
22
- key: Type.Optional(Type.String({ description: "A single agent/task run: the call's key. status with wid: that call's full result." })),
23
- full: Type.Optional(Type.Boolean({ description: "status: with wid, the complete workflow detail including every output." })),
24
- force: Type.Optional(Type.Union([Type.String(), Type.Boolean()], { description: "restart: the token shown by a refusal. Show the user the list and obtain explicit approval first; boolean true is refused. Subagents cannot force a restart." })),
25
- reason: Type.Optional(Type.String({ description: "restart: non-empty reason, at most 500 characters; required with force." })),
26
- request: Type.Optional(Type.String({ description: "run/send/stop: your own request id (1-124 chars [A-Za-z0-9][A-Za-z0-9._:-]*) making a retry safe: the same id with the same content gets the first outcome; other content is refused (request-conflict)." })),
27
- }, { additionalProperties: true });
28
5
  /** Call fields a tasks/chain run applies to every step that does not set its own. */
29
6
  export const stepDefaults = ["model", "timeoutMs", "budget", "isolation", "context", "tools", "skills", "once", "writer"];
30
7
  function string(args, name) {
@@ -13,7 +13,8 @@ import { dsaHome, orchInbox, orchLedger, orchLock, outboxRoot } from "../paths.j
13
13
  import { CT, JT } from "../types.js";
14
14
  import { attention, presentText, presented, resolved, unfinishedWorkflow } from "./main/snapshots.js";
15
15
  import { isLive, pausedElsewhere, runningOrchestrator, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
16
- import { checkAgents, parameters, request, sendReceipt } from "./main/tool.js";
16
+ import { checkAgents, request, sendReceipt } from "./main/tool.js";
17
+ import { parameters } from "./main/schema.js";
17
18
  import { findRequest, requestRid, sendIdentified } from "../requests.js";
18
19
  import { discoverAgents } from "../compat/agents.js";
19
20
  import { restartInputError } from "../orchestrator/restart.js";
@@ -103,11 +103,28 @@ function describeWorkflow(home, wid, entries, now) {
103
103
  return { state, wid, ...(id ? { request: id } : {}), ...(admitted ? { spec_digest: specDigest(admitted) } : {}), status: wf.status, ...(wf.error ? { error: wf.error } : {}),
104
104
  calls, ...(questions.length ? { questions } : {}), ...(attention.length ? { attention } : {}), ...(fence ? { lastFence: fence } : {}) };
105
105
  }
106
- /** R3, best effort: why the latest execution fence happened. restart-force: a forced restart listed the execution as live;
106
+ /** R3, best effort: why the latest fence that interrupted work happened. Every execution ends with a fence; one interrupted
107
+ * work only when the execution neither settled (its turn ended) before it nor hibernated (it waits for an answer), and
108
+ * was not sealed on purpose: a seal ends an execution on purpose unless its outcome is `unknown` (a `once` call cut off
109
+ * in a tool) or the execution was recorded as lost (the loss bound sealed it), which are interruptions themselves.
110
+ * A seal for an execution that never ran (a launch failure) or that the call's stop, timeout or budget ended is on
111
+ * purpose. restart-force: a forced restart listed the execution as live;
107
112
  * 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). */
113
+ * fenced after it (startup recovery); otherwise process-died (the child or its host went away, or a drain fenced it). */
109
114
  export function lastFence(journal, orch) {
110
- const fence = journal.findLast(e => e.type === JT.fenced);
115
+ const lost = new Set(journal.filter(e => e.type === "loss").map(e => String(e.exec)));
116
+ const fencedAt = new Map(journal.filter(e => e.type === JT.fenced).map(e => [String(e.exec), Number(e.seq)]));
117
+ const ended = new Set(journal.filter(e => {
118
+ const exec = String(e.exec);
119
+ // Recovery records `hibernated` after the fence for an execution cut off while only its question's ask ran (P28):
120
+ // it was waiting, not working, so that is no interruption either.
121
+ if (e.type === "hibernated")
122
+ return true;
123
+ if (e.type === "settled")
124
+ return Number(e.seq) < (fencedAt.get(exec) ?? Infinity);
125
+ return e.type === JT.sealed && e.result?.status !== "unknown" && !lost.has(exec);
126
+ }).map(e => String(e.exec)));
127
+ const fence = journal.findLast(e => e.type === JT.fenced && !ended.has(String(e.exec)));
111
128
  if (!fence)
112
129
  return undefined;
113
130
  const exec = String(fence.exec), at = Number(fence.ts);
@@ -183,13 +200,14 @@ function pending(ctx, id, json, why = "") {
183
200
  return EXIT.pending;
184
201
  }
185
202
  /** 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) {
203
+ async function submitAndWait(ctx, seen, id, kind, body, cond, wait, json) {
187
204
  // R2: `created` is false only when the id was already decided before this invocation submitted (decisions are
188
205
  // monotonic); racing first attempts may all report created — the wid is what identifies the run.
189
206
  const rid = requestRid(id), earlier = Boolean(await outcome(ctx.home, rid, kind === "run", 0));
190
207
  let sent;
191
208
  try {
192
209
  sent = await submitIdentified(ctx.home, rid, kind, body, cond, ctx.env, ctx.starter);
210
+ seen.submitted = !("conflict" in sent);
193
211
  }
194
212
  catch (error) {
195
213
  // Exit 1 means decided (rejected) or a usage error. A busy lock submitted nothing, and a failure after the envelope
@@ -209,13 +227,37 @@ async function submitAndWait(ctx, id, kind, body, cond, wait, json) {
209
227
  return { code: await conflict(ctx, id, specDigest(admitted), json) };
210
228
  return { sent, outcome: result, earlier };
211
229
  }
230
+ /** R2: a failure once this invocation submitted, or once the id is found recorded with this content, is "not decided
231
+ * yet" (75), never a refusal. Otherwise, with --json, a request refused before submission (a usage error, an invalid
232
+ * spec, an unknown agent, no open question) answers `{request, applied:false, reason, spec_digest?}` with exit 1;
233
+ * spec_digest is present once the content was complete enough to hash. This invocation submitted nothing. */
234
+ async function refusable(args, ctx, run) {
235
+ const seen = {};
236
+ try {
237
+ return await run(args, ctx, seen);
238
+ }
239
+ catch (error) {
240
+ const reason = error instanceof Error ? error.message : String(error), json = seen.json ?? args.includes("--json");
241
+ const recorded = !seen.submitted && seen.id && seen.digest && REQUEST_ID.test(seen.id) ? await findRequest(ctx.home, requestRid(seen.id)).catch(() => undefined) : undefined;
242
+ if (seen.id && (seen.submitted || recorded && specDigest(recorded.request) === seen.digest))
243
+ return pending(ctx, seen.id, json, `submitted, then: ${reason}`);
244
+ if (!json)
245
+ throw error;
246
+ const at = args.indexOf("--request"), id = seen.id ?? (at >= 0 && at + 1 < args.length ? args[at + 1] : undefined);
247
+ ctx.write(JSON.stringify({ request: id ?? null, applied: false, reason, ...(seen.digest ? { spec_digest: seen.digest } : {}) }));
248
+ return EXIT.rejected;
249
+ }
250
+ }
212
251
  const forks = (spec) => [spec, ...["tasks", "chain"].flatMap(k => Array.isArray(spec[k]) ? spec[k] : [])]
213
252
  .some(s => s && typeof s === "object" && s.context === "fork");
214
253
  /** R2: `run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]`. The spec is the `subagents` run
215
254
  * 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) {
255
+ export const runCommand = (args, ctx) => refusable(args, ctx, runRequest);
256
+ async function runRequest(args, ctx, seen) {
217
257
  const { values, positionals } = flags(args, { request: "value", spec: "value", cwd: "value", json: "flag", "wait-ms": "value" });
218
258
  const id = text(values, "request"), file = text(values, "spec"), json = values.json === true, wait = waitMs(values, ctx);
259
+ seen.id = id;
260
+ seen.json = json;
219
261
  if (!id || !file || positionals.length)
220
262
  throw new Error("usage: run --request <id> --spec <file|-> [--cwd <dir>] [--json] [--wait-ms <n>]");
221
263
  requestRid(id);
@@ -239,8 +281,13 @@ export async function runCommand(args, ctx) {
239
281
  const base = resolve(ctx.cwd ?? process.cwd(), text(values, "cwd") ?? "."), dir = typeof spec.cwd === "string" && spec.cwd ? resolve(base, spec.cwd) : base;
240
282
  const normalized = request({ ...spec, action: "run", ...(typeof spec.cwd === "string" && spec.cwd ? { cwd: dir } : {}) }, dir);
241
283
  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);
284
+ seen.digest = specDigest({ kind: "run", body });
285
+ // An id already recorded is decided by that record: the same content gets its first outcome (the agents it named may
286
+ // have changed since), other content is a request-conflict. Only a new id is checked here.
287
+ const prior = (await findRequest(ctx.home, requestRid(id)))?.request;
288
+ if (!prior)
289
+ checkAgents(body, () => discoverAgents(dir, { home: ctx.env.HOME || undefined, agentDir: ctx.env.PI_CODING_AGENT_DIR || undefined }).agents.map(a => a.name));
290
+ const done = await submitAndWait(ctx, seen, id, "run", body, undefined, wait, json);
244
291
  if ("code" in done)
245
292
  return done.code;
246
293
  if (done.outcome.type === "rejected") {
@@ -283,9 +330,12 @@ async function target(home, to, call, prior) {
283
330
  throw new Error(`${to} has ${keys.length ? `calls ${keys.join(", ")}` : "no calls yet"}; name one with --call <key> or --to <wid>/<key>`);
284
331
  }
285
332
  /** 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) {
333
+ export const sendCommand = (args, ctx) => refusable(args, ctx, sendRequest);
334
+ async function sendRequest(args, ctx, seen) {
287
335
  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
336
  const id = text(values, "request"), to = text(values, "to"), kind = text(values, "kind"), json = values.json === true, wait = waitMs(values, ctx);
337
+ seen.id = id;
338
+ seen.json = json;
289
339
  if (!id || !to || !kind || positionals.length)
290
340
  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
341
  const rid = requestRid(id), prior = (await findRequest(ctx.home, rid))?.request;
@@ -310,15 +360,19 @@ export async function sendCommand(args, ctx) {
310
360
  }
311
361
  const normalized = request({ action: "send", to: where.to, kind, ...(message !== undefined ? { message } : {}), ...(text(values, "model") !== undefined ? { model: text(values, "model") } : {}),
312
362
  ...(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);
363
+ seen.digest = specDigest({ kind: "send", body: normalized.body, cond: normalized.cond });
364
+ const done = await submitAndWait(ctx, seen, id, "send", normalized.body, normalized.cond, wait, json);
314
365
  if ("code" in done)
315
366
  return done.code;
316
367
  return decided(ctx, id, done, json);
317
368
  }
318
369
  /** R2: `stop --request <id> <run-id|wid|wid/key|callId>`. */
319
- export async function stopCommand(args, ctx) {
370
+ export const stopCommand = (args, ctx) => refusable(args, ctx, stopRequest);
371
+ async function stopRequest(args, ctx, seen) {
320
372
  const { values, positionals } = flags(args, { request: "value", json: "flag", "wait-ms": "value" });
321
373
  const id = text(values, "request"), json = values.json === true, wait = waitMs(values, ctx);
374
+ seen.id = id;
375
+ seen.json = json;
322
376
  if (!id || positionals.length !== 1)
323
377
  throw new Error("usage: stop --request <id> <run-id|wid|wid/key> [--json]");
324
378
  requestRid(id);
@@ -327,7 +381,8 @@ export async function stopCommand(args, ctx) {
327
381
  if ("pending" in resolved)
328
382
  return pending(ctx, id, json, `run ${head} has no workflow yet`);
329
383
  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);
384
+ seen.digest = specDigest({ kind: "stop", body: normalized.body });
385
+ const done = await submitAndWait(ctx, seen, id, "stop", normalized.body, undefined, wait, json);
331
386
  if ("code" in done)
332
387
  return done.code;
333
388
  return decided(ctx, id, done, json);
@@ -1,10 +1,10 @@
1
1
  // Private workflow entries (P28): hibernated{call,qid,rev,exec};
2
2
  // answer-bound{call,qid,rev,rid,rid2,message,hash}; resumed{call,rid,exec}.
3
3
  import { CT } from "../../types.js";
4
- import { receiptId } from "./session.js";
4
+ import { askOf, cutAskResult, receiptId } from "./session.js";
5
5
  /** P28, V4: Find an unanswered question whose native ask tool remains blocked. */
6
6
  export function openQuestion(entries) {
7
- const q = entries.findLast(e => e.type === "custom" && e.customType === CT.question)?.data;
7
+ const at = entries.findLastIndex(e => e.type === "custom" && e.customType === CT.question), q = entries[at]?.data;
8
8
  if (!q || typeof q.qid !== "string" || typeof q.rev !== "number")
9
9
  return;
10
10
  if (entries.some(e => {
@@ -12,17 +12,12 @@ export function openQuestion(entries) {
12
12
  return !!details && details.qid === q.qid && details.rev === q.rev && receiptId(e) !== undefined;
13
13
  }))
14
14
  return;
15
- const pending = new Set();
16
- for (const e of entries) {
17
- const m = e.message;
18
- if (m?.role === "assistant" && Array.isArray(m.content))
19
- for (const b of m.content)
20
- if (b.type === "toolCall" && b.name === "ask" && b.id)
21
- pending.add(b.id);
22
- if (m?.role === "toolResult" && m.toolCallId)
23
- pending.delete(m.toolCallId);
24
- }
25
- if (!pending.size)
15
+ // Only the ask that wrote this question counts: it is still blocked when it has no result, or only the error it ended
16
+ // with when its session shut down or its run was aborted. An earlier ask cut off that way does not keep a later
17
+ // question (ended by a steer, say) open.
18
+ const ask = askOf(entries, at);
19
+ const result = ask === undefined ? undefined : entries.find(e => e.message?.role === "toolResult" && e.message.toolCallId === ask);
20
+ if (ask === undefined || result && !cutAskResult(result.message))
26
21
  return;
27
22
  return { qid: q.qid, rev: q.rev, question: String(q.question) };
28
23
  }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { open } from "node:fs/promises";
3
- import { CT } from "../../types.js";
3
+ import { ASK_CUT, CT } from "../../types.js";
4
4
  // A child session is append-only while observed (pi appends whole lines), so a cached state is extended by parsing only
5
5
  // the bytes appended since the last read. A different inode, a shorter file or a changed first 4 KiB (pi rewrites a
6
6
  // session in place when it migrates or initializes it) is read from scratch.
@@ -75,12 +75,37 @@ export function receiptId(entry) {
75
75
  (entry.type === "custom" && [CT.rejected, CT.withdrawn, CT.model].includes(entry.customType) ? entry.data?.rid : undefined);
76
76
  return typeof rid === "string" ? rid : undefined;
77
77
  }
78
+ /** P28: an `ask` result the child wrote because its session shut down or its run was aborted, which is no answer. */
79
+ export function cutAskResult(m) {
80
+ if (m?.role !== "toolResult" || !m.isError)
81
+ return false;
82
+ const text = typeof m.content === "string" ? m.content : (m.content ?? []).map(b => b.text ?? "").join("");
83
+ return text === ASK_CUT.shutdown || text === ASK_CUT.aborted;
84
+ }
85
+ /** P28: the `ask` tool call that wrote the question at `index`. The child appends a question while that ask runs, after
86
+ * the assistant message that called it; a message with an ask runs its tools in order, so it is that message's first
87
+ * ask with no result before the question. */
88
+ export function askOf(entries, index) {
89
+ for (let i = index - 1; i >= 0; i--) {
90
+ const m = entries[i].message;
91
+ if (m?.role !== "assistant" || !Array.isArray(m.content))
92
+ continue;
93
+ const asks = m.content.filter(b => b.type === "toolCall" && b.name === "ask" && b.id);
94
+ if (!asks.length)
95
+ continue;
96
+ const done = new Set(entries.slice(i + 1, index).map(e => e.message?.role === "toolResult" ? e.message.toolCallId : undefined));
97
+ return (asks.find(b => !done.has(b.id)) ?? asks.at(-1)).id;
98
+ }
99
+ return undefined;
100
+ }
78
101
  /** P9: Derive evidence only after this execution's own launch receipt. */
79
102
  export function evidence(entries, exec) {
80
103
  const start = entries.findLastIndex(e => e.type === "custom" && e.customType === CT.exec && e.data?.exec === exec);
81
104
  const segment = start < 0 ? [] : entries.slice(start + 1);
82
105
  const report = segment.findLast(e => e.type === "custom" && e.customType === CT.report && e.data?.exec === exec && ["ok", "failed"].includes(String(e.data.outcome)))?.data;
83
106
  const tools = new Map();
107
+ // P28: only an ask that wrote a question can be cut off while waiting; one aborted before that is an ordinary result.
108
+ const asked = new Set(segment.flatMap((e, i) => e.type === "custom" && e.customType === CT.question ? [askOf(segment, i)] : []));
84
109
  let text = "";
85
110
  const usage = { input: 0, output: 0, costUsd: 0 };
86
111
  for (const e of segment) {
@@ -99,7 +124,8 @@ export function evidence(entries, exec) {
99
124
  usage.output += m.usage?.output ?? 0;
100
125
  usage.costUsd += m.usage?.cost?.total ?? 0;
101
126
  }
102
- if (m?.role === "toolResult" && m.toolCallId)
127
+ // P28: an ask cut off by shutdown or abort while its question waited keeps an unknown outcome, like one with no result.
128
+ if (m?.role === "toolResult" && m.toolCallId && !(asked.has(m.toolCallId) && cutAskResult(m)))
103
129
  tools.delete(m.toolCallId);
104
130
  }
105
131
  const budget = segment.some(e => e.type === "custom" && e.customType === CT.budget && e.data?.exec === exec);
package/dist/types.js CHANGED
@@ -11,6 +11,9 @@ export function attentionEntries(entries, id) {
11
11
  // Child session entries (pi session file is the child's log; P4, P8, P23, P24)
12
12
  // customType values written by the session agent. All carry details/data as below.
13
13
  // ---------------------------------------------------------------------------
14
+ /** P28: the errors the child's own `ask` ends with when its session shuts down or its run is aborted. Such a result is
15
+ * no answer: recovery treats the ask as cut off while waiting, like one with no result. */
16
+ export const ASK_CUT = { shutdown: "Session shut down", aborted: "Ask aborted" };
14
17
  export const CT = {
15
18
  admitted: "dsa-admitted", // custom: { rid, from, sseq, hash, kind } — lifecycle admission record (kernel DecisionRecord)
16
19
  exec: "dsa-exec", // custom: { exec } — start of an execution segment
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",