pi-durable-subagents 1.0.22 → 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,15 @@
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
+
3
13
  ## 1.0.22
4
14
 
5
15
  - The installed CLI runs `run`, `send` and `stop` again: 1.0.21 loaded the
@@ -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;
@@ -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.22",
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",