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 +33 -0
- package/README.md +9 -5
- package/dist/agent/child.js +4 -4
- package/dist/agent/main/schema.js +25 -0
- package/dist/agent/main/tool.js +0 -23
- package/dist/agent/main.js +2 -1
- package/dist/cli/requests.js +66 -11
- package/dist/orchestrator/executor/hibernate.js +8 -13
- package/dist/orchestrator/executor/session.js +28 -2
- package/dist/types.js +3 -0
- package/package.json +1 -1
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
|
|
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)
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
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;
|
|
@@ -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 });
|
package/dist/agent/main/tool.js
CHANGED
|
@@ -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) {
|
package/dist/agent/main.js
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/cli/requests.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|