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
|
package/dist/agent/child.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|