pi-durable-subagents 1.0.7 → 1.0.9
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 +36 -0
- package/README.md +8 -2
- package/dist/agent/main/snapshots.js +117 -42
- package/dist/agent/main/tool.js +10 -1
- package/dist/agent/main.js +5 -4
- package/dist/cli/main.js +1 -1
- package/dist/orchestrator/config.js +8 -4
- package/dist/orchestrator/engine.js +23 -2
- package/dist/orchestrator/executor/index.js +126 -36
- package/dist/orchestrator/executor/session.js +8 -0
- package/dist/orchestrator/main.js +4 -3
- package/dist/orchestrator/snapshot.js +273 -177
- package/dist/ui/data.js +42 -6
- package/dist/ui/screen.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.9
|
|
4
|
+
|
|
5
|
+
- `status` shows the model a call actually uses: the model of its last
|
|
6
|
+
provider request. A requested switch not used yet shows as `switching`
|
|
7
|
+
(also in the brief status), a switch the subagent refused as
|
|
8
|
+
`switchFailed`. Before, `status` kept the model the execution started with,
|
|
9
|
+
so a switch that had worked looked as if it had not.
|
|
10
|
+
- `send follow-up` with `model` runs the new generation on that model; it was
|
|
11
|
+
ignored, and the generation continued on the session's model. A send that
|
|
12
|
+
names a model replies with `model` and `effect`: `next-request` (a running
|
|
13
|
+
call switches at its next request), `next-execution` or `next-generation`.
|
|
14
|
+
- `send model` to a call that is asking (hibernated) or still waiting for a
|
|
15
|
+
slot is recorded and applied when it runs again, instead of being refused
|
|
16
|
+
with `call-not-running`.
|
|
17
|
+
A requested model applies once: afterwards the session and the pool choose
|
|
18
|
+
as before. Withdrawing a follow-up that names a model withdraws the switch
|
|
19
|
+
too.
|
|
20
|
+
- A provider's refusal of the content (terms of service, usage policy) fails
|
|
21
|
+
the call at once with that error. It was retried as a lost execution five
|
|
22
|
+
times and reported as `lost ×5`.
|
|
23
|
+
|
|
24
|
+
## 1.0.8
|
|
25
|
+
|
|
26
|
+
- pi stays responsive with a long history: the extension's polling in pi's
|
|
27
|
+
main thread no longer re-reads it all. Workflow status folds only what a
|
|
28
|
+
journal appended, the orchestrator ledger is indexed once per change, the
|
|
29
|
+
attention check reads only this session's workflows, and an open question's
|
|
30
|
+
card reads only what the child session appended. On a home with 60
|
|
31
|
+
workflows and 356 calls, the dock refresh went from 4.5 ms to under 1 ms
|
|
32
|
+
every half second, and an idle pi's busy time from about 4% to 1.5%.
|
|
33
|
+
- Calls finished more than 10 s ago keep their summary only; their transcript
|
|
34
|
+
is read when shown. pi holds about 100 MB less and garbage collection no
|
|
35
|
+
longer pauses typing.
|
|
36
|
+
- A change of `config.json` written twice quickly to the same size is no
|
|
37
|
+
longer missed: the orchestrator compares the file's content.
|
|
38
|
+
|
|
3
39
|
## 1.0.7
|
|
4
40
|
|
|
5
41
|
- Changes to `config.json` (`defaultModel`, `pools`, `providers`, `memory`,
|
package/README.md
CHANGED
|
@@ -77,6 +77,12 @@ answer) or failed, and one line per finished workflow. `status` with a wid
|
|
|
77
77
|
shows one workflow with outputs clipped; add `key` for one call's full result,
|
|
78
78
|
or `full: true` for everything. When a run replies `{submitted: {rid}}`
|
|
79
79
|
(its workflow was not created within 10 s), the rid works wherever a wid does.
|
|
80
|
+
A call's `model` in `status` is the model its last provider request used; a
|
|
81
|
+
requested switch not used yet shows as `switching`, a refused one as
|
|
82
|
+
`switchFailed`. A send naming a model replies with `model` and `effect`
|
|
83
|
+
(`next-request`, `next-execution` or `next-generation`).
|
|
84
|
+
A provider's refusal of the content (terms of service, usage policy) fails the
|
|
85
|
+
call at once with that error instead of retrying it.
|
|
80
86
|
With `tasks` or `chain`, top-level `model`, `timeoutMs`, `budget`, `isolation`,
|
|
81
87
|
`context`, `tools`, `skills` and `once` apply to every step that does not set
|
|
82
88
|
its own; other call fields there, and any of them beside a workflow script,
|
|
@@ -90,9 +96,9 @@ it something. Each verb means one thing, and a refusal says what would work:
|
|
|
90
96
|
|---|---|---|
|
|
91
97
|
| `run` | — | Start one subagent, `tasks` in parallel, a `chain`, or a workflow script. An unknown agent name is refused before anything starts, with the list of agents. |
|
|
92
98
|
| `send steer` | a running subagent | Reaches it at its next safe point. To a finished one: refused, use `follow-up`; To one waiting on its question: it interrupts the question, and the subagent usually asks again; `answer` answers it. |
|
|
93
|
-
| `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). |
|
|
99
|
+
| `send follow-up` | a finished subagent | Continues the same session as a new generation (`key@2`). With `model`, that generation runs on it. |
|
|
94
100
|
| `send answer` | an open question | Answers it once. |
|
|
95
|
-
| `send model` | any subagent |
|
|
101
|
+
| `send model` | any subagent | A running one switches at its next request; one asking, hibernated or waiting for a slot launches on it when it runs again. |
|
|
96
102
|
| `stop` | a subagent or a workflow | Final: `stopped`, usage kept, edits left as they are. |
|
|
97
103
|
| `drain` / `resume` | existing workflows | A reversible hold; runs started later are not held. |
|
|
98
104
|
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { readdirSync,
|
|
1
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { readJournalSnapshot } from "../../kernel/journal.js";
|
|
4
5
|
import { journalPath, orchLedger } from "../../paths.js";
|
|
5
6
|
import { CT, JT } from "../../types.js";
|
|
6
|
-
import { holdOf } from "../../orchestrator/snapshot.js";
|
|
7
|
+
import { holdOf, ledgerIndex } from "../../orchestrator/snapshot.js";
|
|
7
8
|
/** P15, P25: Read workflow snapshots without modifying another domain's history; a wid with an orchestrator.jsonl
|
|
8
9
|
* `pruned{wid}` entry is gone (housekeeping), even while its directory is still being removed. */
|
|
9
|
-
export function workflows(home) {
|
|
10
|
-
const ledger = readJournalSnapshot(orchLedger(home));
|
|
10
|
+
export function workflows(home, origin) {
|
|
11
|
+
const ledger = readJournalSnapshot(orchLedger(home)), { created: createdBy, pruned } = ledgerIndex(ledger);
|
|
11
12
|
let names;
|
|
12
13
|
try {
|
|
13
14
|
names = readdirSync(join(home, "w"));
|
|
@@ -17,21 +18,38 @@ export function workflows(home) {
|
|
|
17
18
|
throw error;
|
|
18
19
|
names = [];
|
|
19
20
|
}
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
const ids = new Set([...[...createdBy.keys()].map(String), ...names].filter(wid => !pruned.has(wid)));
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const wid of [...ids].sort()) {
|
|
24
|
+
// With an origin wanted, a workflow the ledger attributes to another session is skipped without reading its journal.
|
|
25
|
+
const known = createdBy.get(wid);
|
|
26
|
+
if (origin !== undefined && known && known.origin !== origin)
|
|
27
|
+
continue;
|
|
27
28
|
const entries = readJournalSnapshot(journalPath(home, wid));
|
|
28
|
-
const created =
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const created = known ?? entries.find(e => e.type === JT.created);
|
|
30
|
+
if (origin !== undefined && created?.origin !== origin)
|
|
31
|
+
continue;
|
|
32
|
+
result.push({ wid, origin: created?.origin, entries });
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
31
35
|
}
|
|
32
36
|
/** P37: a generation opened by a send (possibly after the workflow finished) that has no seal yet is pending work. */
|
|
37
|
+
const opened = new WeakMap();
|
|
33
38
|
export function openGeneration(entries) {
|
|
34
|
-
|
|
39
|
+
const hit = opened.get(entries);
|
|
40
|
+
if (hit?.length === entries.length)
|
|
41
|
+
return hit.open;
|
|
42
|
+
// Sealed call ids end in `/<key>@<gen>`; collect every such suffix once instead of rescanning per generation.
|
|
43
|
+
const sealed = new Set();
|
|
44
|
+
for (const s of entries)
|
|
45
|
+
if (s.type === JT.sealed) {
|
|
46
|
+
const call = String(s.call);
|
|
47
|
+
for (let i = call.indexOf("/"); i >= 0; i = call.indexOf("/", i + 1))
|
|
48
|
+
sealed.add(call.slice(i));
|
|
49
|
+
}
|
|
50
|
+
const open = entries.some(g => g.type === "generation" && !sealed.has(`/${String(g.key)}@${String(g.gen)}`));
|
|
51
|
+
opened.set(entries, { length: entries.length, open });
|
|
52
|
+
return open;
|
|
35
53
|
}
|
|
36
54
|
/** P1: Workflow-side pending work shared by every starter: a workflow without JT.done or with an unsealed generation,
|
|
37
55
|
* unless a drain (stop-all) holds it: held work waits for an explicit resume, which starts the orchestrator itself. */
|
|
@@ -65,9 +83,7 @@ function unresolved(entries) {
|
|
|
65
83
|
/** P15, V7: Select only unresolved, unpresented items owned by this session. */
|
|
66
84
|
export function attention(home, sender, seen) {
|
|
67
85
|
const items = [], shown = new Set(seen.map(s => `${s.id}\0${s.rev}`));
|
|
68
|
-
for (const workflow of workflows(home)) {
|
|
69
|
-
if (workflow.origin !== sender)
|
|
70
|
-
continue;
|
|
86
|
+
for (const workflow of workflows(home, sender)) {
|
|
71
87
|
for (const item of unresolved(workflow.entries)) {
|
|
72
88
|
const key = `${item.id}\0${item.rev}`;
|
|
73
89
|
if (shown.has(key))
|
|
@@ -86,37 +102,96 @@ export function presentText(item) {
|
|
|
86
102
|
return `Question from ${call}${item.qid ? ` (qid ${item.qid})` : ""}; reply with send kind:"answer" to:"${call}": ${item.text}`;
|
|
87
103
|
return item.text.includes(item.wid) ? item.text : `${where}: ${item.text}`;
|
|
88
104
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
105
|
+
/** Resolutions recorded in one immutable journal snapshot, keyed by id and rev. */
|
|
106
|
+
const resolutions = new WeakMap();
|
|
107
|
+
function resolvedIn(entries) {
|
|
108
|
+
let done = resolutions.get(entries);
|
|
109
|
+
if (!done) {
|
|
110
|
+
done = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => JSON.stringify([e.id, e.rev])));
|
|
111
|
+
resolutions.set(entries, done);
|
|
112
|
+
}
|
|
113
|
+
return done;
|
|
114
|
+
}
|
|
115
|
+
const asks = new Map(), ASK_SCANS = 64, MARK = 64;
|
|
116
|
+
function ask(scan, line) {
|
|
117
|
+
if (!line.includes('"ask"'))
|
|
118
|
+
return;
|
|
119
|
+
let entry;
|
|
120
|
+
try {
|
|
121
|
+
entry = JSON.parse(line);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return;
|
|
125
|
+
} // a malformed line is skipped, as pi and the session tail do (E4)
|
|
126
|
+
const msg = entry?.type === "message" ? entry.message : undefined;
|
|
127
|
+
if (msg?.role === "toolResult" && msg.toolName === "ask" && !msg.isError)
|
|
128
|
+
scan.answered.add(JSON.stringify([msg.details?.qid, msg.details?.rev]));
|
|
129
|
+
}
|
|
130
|
+
function answeredAsks(path) {
|
|
131
|
+
let stat;
|
|
96
132
|
try {
|
|
97
|
-
|
|
133
|
+
stat = statSync(path);
|
|
98
134
|
}
|
|
99
135
|
catch (error) {
|
|
100
|
-
if (error.code === "ENOENT")
|
|
101
|
-
|
|
136
|
+
if (error.code === "ENOENT") {
|
|
137
|
+
asks.delete(path);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
102
140
|
throw error;
|
|
103
141
|
}
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
142
|
+
const identity = `${stat.dev}:${stat.ino}`;
|
|
143
|
+
let scan = asks.get(path);
|
|
144
|
+
if (scan) {
|
|
145
|
+
asks.delete(path);
|
|
146
|
+
asks.set(path, scan);
|
|
147
|
+
} // least recently used first
|
|
148
|
+
const fd = openSync(path, "r");
|
|
149
|
+
try {
|
|
150
|
+
// Appending keeps what was read: the bytes just before the old end are still there. Anything else (a replaced or
|
|
151
|
+
// shorter file, other bytes before the old end) is a rewrite, read again from the start. File times are too coarse
|
|
152
|
+
// to tell a quick rewrite of the same size, so the bytes are compared.
|
|
153
|
+
let kept = !!scan && scan.identity === identity && stat.size >= scan.offset;
|
|
154
|
+
if (kept && scan.mark.length) {
|
|
155
|
+
const now = Buffer.alloc(scan.mark.length);
|
|
156
|
+
kept = readSync(fd, now, 0, now.length, scan.offset - now.length) === now.length && now.equals(scan.mark);
|
|
157
|
+
}
|
|
158
|
+
if (kept && stat.size === scan.offset)
|
|
159
|
+
return scan.answered;
|
|
160
|
+
if (!kept) {
|
|
161
|
+
scan = { identity, offset: 0, mark: Buffer.alloc(0), pending: "", decoder: new StringDecoder("utf8"), answered: new Set() };
|
|
162
|
+
asks.set(path, scan);
|
|
163
|
+
if (asks.size > ASK_SCANS)
|
|
164
|
+
asks.delete(asks.keys().next().value);
|
|
111
165
|
}
|
|
112
|
-
|
|
113
|
-
|
|
166
|
+
const s = scan;
|
|
167
|
+
const buffer = Buffer.alloc(Math.min(256 * 1024, Math.max(1, stat.size - s.offset)));
|
|
168
|
+
while (s.offset < stat.size) {
|
|
169
|
+
const n = readSync(fd, buffer, 0, Math.min(buffer.length, stat.size - s.offset), s.offset);
|
|
170
|
+
if (!n)
|
|
114
171
|
break;
|
|
115
|
-
|
|
172
|
+
s.offset += n;
|
|
173
|
+
s.pending += s.decoder.write(buffer.subarray(0, n));
|
|
174
|
+
s.mark = Buffer.concat([s.mark, buffer.subarray(0, n)]).subarray(-MARK);
|
|
175
|
+
let end;
|
|
176
|
+
while ((end = s.pending.indexOf("\n")) >= 0) {
|
|
177
|
+
ask(s, s.pending.slice(0, end));
|
|
178
|
+
s.pending = s.pending.slice(end + 1);
|
|
179
|
+
}
|
|
116
180
|
}
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
181
|
+
// A last line without its newline counts once it is complete JSON; it stays pending until the newline arrives.
|
|
182
|
+
if (s.pending)
|
|
183
|
+
ask(s, s.pending);
|
|
184
|
+
return s.answered;
|
|
120
185
|
}
|
|
121
|
-
|
|
186
|
+
finally {
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** P15: Refresh a question against durable workflow and child receipts at request time. */
|
|
191
|
+
export function resolved(home, item) {
|
|
192
|
+
if (resolvedIn(readJournalSnapshot(journalPath(home, item.wid))).has(JSON.stringify([item.id, item.rev])))
|
|
193
|
+
return true;
|
|
194
|
+
if (item.kind !== "question" || !item.session || !item.qid)
|
|
195
|
+
return false;
|
|
196
|
+
return answeredAsks(item.session)?.has(JSON.stringify([item.qid, item.rev])) ?? false;
|
|
122
197
|
}
|
package/dist/agent/main/tool.js
CHANGED
|
@@ -39,6 +39,13 @@ function call(value, cwd, where) {
|
|
|
39
39
|
return spec;
|
|
40
40
|
}
|
|
41
41
|
/** v12 §2: Infer unambiguous runs and normalize controls into unchanged wire bodies. */
|
|
42
|
+
/** P12: a send naming a model is answered with that model and when it applies — `next-request` (a running call switches
|
|
43
|
+
* at its next provider request), `next-execution` (a call with no live execution launches on it) or `next-generation`
|
|
44
|
+
* (a follow-up's new generation runs on it). From the orchestrator ledger's `send-note`. */
|
|
45
|
+
export function sendReceipt(ledger, rid) {
|
|
46
|
+
const note = ledger.find(e => e.type === "send-note" && e.rid === rid);
|
|
47
|
+
return note ? { model: String(note.model), effect: String(note.effect) } : {};
|
|
48
|
+
}
|
|
42
49
|
export function request(args, cwd) {
|
|
43
50
|
// v12 §2: Infer run only when one launch form is present; never guess a control verb.
|
|
44
51
|
const launchForms = [args.agent !== undefined || args.task !== undefined, args.tasks !== undefined,
|
|
@@ -110,7 +117,9 @@ export function request(args, cwd) {
|
|
|
110
117
|
const kind = string(args, "kind");
|
|
111
118
|
if (!["steer", "follow-up", "answer", "model"].includes(kind))
|
|
112
119
|
throw new Error("Unsupported send kind");
|
|
113
|
-
|
|
120
|
+
// follow-up may name the model its continuation runs on (P37); other kinds ignore one.
|
|
121
|
+
const model = kind === "model" || kind === "follow-up" && args.model !== undefined ? { model: string(args, "model") } : {};
|
|
122
|
+
const body = { to: string(args, "to"), kind, ...model, ...(kind === "model" ? {} : { message: string(args, "message") }), ...(args.by === "user" ? { by: "user" } : {}) };
|
|
114
123
|
const cond = {};
|
|
115
124
|
if (kind === "answer") {
|
|
116
125
|
cond.qid = string(args, "qid");
|
package/dist/agent/main.js
CHANGED
|
@@ -13,7 +13,7 @@ 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, statusBrief, statusCallDetail, statusCompactDetail, statusDetail, statusView, widOfRid } from "../orchestrator/snapshot.js";
|
|
16
|
-
import { parameters, request } from "./main/tool.js";
|
|
16
|
+
import { parameters, request, sendReceipt } from "./main/tool.js";
|
|
17
17
|
import { discoverAgents } from "../compat/agents.js";
|
|
18
18
|
let noteSink;
|
|
19
19
|
/** P16: Queue a UI note for the next boundary without waking the model. */
|
|
@@ -303,8 +303,9 @@ export function registerMain(pi, ui) {
|
|
|
303
303
|
// The rid is returned so a later send can supersede this one (replaces: [rid]).
|
|
304
304
|
if (decision.type === "rejected")
|
|
305
305
|
return { applied: false, reason: sent.kind === "resume" && args.wid === undefined ? resumeElsewhere(String(decision.reason)) : decision.reason, rid: sent.rid };
|
|
306
|
-
if (sent.kind !== "run")
|
|
307
|
-
return { applied: true, rid: sent.rid };
|
|
306
|
+
if (sent.kind !== "run") {
|
|
307
|
+
return { applied: true, rid: sent.rid, ...sendReceipt(ledger(), sent.rid) };
|
|
308
|
+
}
|
|
308
309
|
}
|
|
309
310
|
if (performance.now() >= deadline || signal?.aborted)
|
|
310
311
|
break;
|
|
@@ -324,7 +325,7 @@ export function registerMain(pi, ui) {
|
|
|
324
325
|
"Durable asynchronous subagents; run returns {wid} when created (or {submitted:{rid}} while pending). A finished workflow (its notice carries every agent's result) or a question wakes you, so after starting work end your turn: never poll with sleep or repeated status. Crash recovery resumes sessions, not external side effects. Background helper processes (orchestrator, evaluator) exit by themselves about 10 s after all work ends: never kill processes or delete files to 'clean up'. When the user quits pi, this session's running workflows pause (nothing is spent); resume continues them.",
|
|
325
326
|
"run (action optional for exactly one launch form): agent+task; tasks:[call specs] parallel; chain:[call specs] sequential ({previous}); workflow:'./script.js' or source (runs.run(key,spec), runs.all([...]), emit(value), args, runs.input(name)). Optional name, cwd, usageBudget, maxCalls, inputs. With tasks/chain, top-level model, timeoutMs, budget, isolation, context, tools, skills, once are defaults for every step (a step's own value wins); a workflow/source script sets them per runs.run call. timeoutMs is milliseconds of active time (a number); omit it unless a hard limit is needed. Explicit unknown agents are rejected BEFORE creation, with available names; unknown script agents fail only their call.",
|
|
326
327
|
"agents: list names, descriptions, default models and source for this cwd; use these names for run.",
|
|
327
|
-
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model switches at next provider request. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
328
|
+
"send to:'<wid>/<key>' (bare '<wid>' only for a single-call workflow): steer on a running call delivers at the next safe point (receipt in status/UI); a steer to a call waiting on its question interrupts the question and the subagent usually asks again — use answer to answer it; sealed → finished:<status> — use kind 'follow-up'. follow-up continues a sealed call as generation g+1 or queues after a running turn; follow-up model:'provider/id' runs that generation on it. answer: give the qid (or just the call, or nothing when one question is open); to and rev are filled in. A question that needs the user's decision goes to the user; if you answer one yourself, tell the user what you chose. model: a running call switches at its next provider request; an asking, hibernated or queued call launches on it when it runs again; the reply's model/effect (next-request|next-execution|next-generation) says which. status model = model actually used by the last request; switching = requested, not used yet; switchFailed = refused. A provider content refusal (ToS/usage policy) fails the call at once, not retried. Unknown targets list valid addresses. replaces:[rid] supersedes an earlier send.",
|
|
328
329
|
"stop target:<wid|<wid>/<key>> is terminal stopped (usage and partial edits kept); a sealed call → already-sealed:<status>, a finished workflow → terminal:<status>. drain holds existing workflows reversibly (new runs unaffected); resume [wid] releases held workflows. status: without wid, what runs, asks (with its answer address; hibernated:true holds no slot) or failed, finished workflows one line each, provider slots held/limit and the config in effect; wid: one workflow, outputs clipped; wid+key: one call's full result; full:true: everything. A run's rid from {submitted:{rid}} works wherever a wid is expected. revise wid + workflow/source/args starts a revision.",
|
|
329
330
|
"Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
|
|
330
331
|
...(agents ? [`Available agents: ${agents}.`] : []),
|
package/dist/cli/main.js
CHANGED
|
@@ -65,7 +65,7 @@ export function renderStatus(wf) {
|
|
|
65
65
|
/** P25, T10: Render the compact status projection shared with the `subagents` tool. */
|
|
66
66
|
export function renderView(view) {
|
|
67
67
|
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.tools ? ` tools:${c.tools}` : ""}${c.usage ? ` ${formatUsage(c.usage)}` : ""}${c.lastLine ? ` ${JSON.stringify(c.lastLine)}` : c.error ? ` (${c.error})` : ""}`),
|
|
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})` : ""}`),
|
|
69
69
|
...w.attention.map(a => ` ${a.kind}: ${JSON.stringify(a.text.split("\n")[0])}`)].join("\n"));
|
|
70
70
|
if (view.paused)
|
|
71
71
|
lines.unshift(`${view.paused} (pi-durable-subagents resume)`);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// config-rejected{hash,error} — a changed file that was not applied; the settings before it stay in effect.
|
|
5
5
|
// A reload changes the shared config object in place: every later read sees it (the next slot acquisition, model
|
|
6
6
|
// resolution or check). Slots already held are kept when a limit drops; timers of running executions keep their period.
|
|
7
|
-
import { readFile
|
|
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. */
|
|
@@ -85,10 +85,11 @@ function applyInPlace(target, next) {
|
|
|
85
85
|
Object.assign(t, orchestratorSettings(next));
|
|
86
86
|
}
|
|
87
87
|
/** Identity of the file's current version (taken before a read, so a change during the read is seen next time). */
|
|
88
|
+
/** The version of config.json: its content. File times are too coarse to tell two quick writes of the same size apart
|
|
89
|
+
* (a change could be missed for good), and the file is small enough to read once a second. */
|
|
88
90
|
export async function configStamp(path) {
|
|
89
91
|
try {
|
|
90
|
-
|
|
91
|
-
return `${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
92
|
+
return `=${await readFile(path, "utf8")}`;
|
|
92
93
|
}
|
|
93
94
|
catch (error) {
|
|
94
95
|
if (error.code === "ENOENT")
|
|
@@ -96,6 +97,8 @@ export async function configStamp(path) {
|
|
|
96
97
|
throw error;
|
|
97
98
|
}
|
|
98
99
|
}
|
|
100
|
+
/** The settings of a version read by `configStamp`; invalid JSON throws, as `readConfig` does. */
|
|
101
|
+
export function stampedConfig(stamp) { return stamp === "missing" ? {} : JSON.parse(stamp.slice(1)); }
|
|
99
102
|
/** Watch config.json from the version `stamp` (read at start) and apply each valid change in place. */
|
|
100
103
|
/** `apply` runs the change where readers cannot observe half of it (the executor's admission section). */
|
|
101
104
|
export function watchConfig(options) {
|
|
@@ -106,8 +109,9 @@ export function watchConfig(options) {
|
|
|
106
109
|
if (now === stamp || stopped)
|
|
107
110
|
return;
|
|
108
111
|
let raw;
|
|
112
|
+
// The content compared is the content applied: a second read could see another version than the stamp.
|
|
109
113
|
try {
|
|
110
|
-
raw =
|
|
114
|
+
raw = stampedConfig(now);
|
|
111
115
|
}
|
|
112
116
|
catch (error) {
|
|
113
117
|
// A half-written file reads as invalid JSON: retry on the next change of the file, report it once per version.
|
|
@@ -22,6 +22,7 @@ import { EvaluatorClient } from "./evaluator-client.js";
|
|
|
22
22
|
import { Store, revisionEntries, terminalEntry } from "./store.js";
|
|
23
23
|
import { formatUsage, holdOf, refusedResult, snapshotFromEntries } from "./snapshot.js";
|
|
24
24
|
import { validateCallSpec } from "../compat/spec.js";
|
|
25
|
+
import { parseModel } from "../compat/model.js";
|
|
25
26
|
const tail = (text, n) => text.length > n ? `…${text.slice(-(n - 1))}` : text;
|
|
26
27
|
const charged = (u) => u && (u.input || u.output || u.costUsd) ? formatUsage(u) : undefined;
|
|
27
28
|
const wakeStatus = (status) => {
|
|
@@ -330,12 +331,26 @@ export class Engine {
|
|
|
330
331
|
const seal = wf.journal.entries().find(e => e.type === JT.sealed && e.call === from);
|
|
331
332
|
if (seal && send.kind === 'steer')
|
|
332
333
|
return { action: 'reject', reason: `finished:${seal.result.status} — use kind "follow-up" to continue it` };
|
|
334
|
+
if (send.kind === 'follow-up' && send.model !== undefined) {
|
|
335
|
+
try {
|
|
336
|
+
if (!parseModel(send.model).provider)
|
|
337
|
+
throw new Error('missing provider');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return { action: 'reject', reason: 'unknown-model' };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
333
343
|
if (seal && send.kind === 'follow-up') {
|
|
334
344
|
const gen = Math.max(0, ...wf.journal.entries().filter(e => ['call', 'generation'].includes(e.type) && e.key === entry.key).map(e => Number(e.gen))) + 1;
|
|
335
|
-
|
|
345
|
+
// A follow-up's model replaces the continued session's for this generation and those continuing it.
|
|
346
|
+
const spec = send.model !== undefined ? { ...entry.spec, model: send.model } : entry.spec;
|
|
347
|
+
if (send.model !== undefined)
|
|
348
|
+
await this.note(req.rid, send.model, 'next-generation');
|
|
349
|
+
const opened = await wf.journal.append('generation', { rid: req.rid, key: entry.key, gen, from, spec, revision: wf.revision, opening: { rid: req.rid, kind: send.kind, message: send.message ?? '' }, ...(send.model !== undefined ? { model: send.model } : {}) });
|
|
336
350
|
this.dispatchGeneration(wf, opened);
|
|
337
351
|
return { action: 'apply' };
|
|
338
352
|
}
|
|
353
|
+
// A follow-up naming a model, queued on unfinished work: the executor records its model request with the message.
|
|
339
354
|
return this.executor.forward(req, this.context(wf, entry));
|
|
340
355
|
}
|
|
341
356
|
else if (req.kind === 'stop') {
|
|
@@ -504,6 +519,11 @@ export class Engine {
|
|
|
504
519
|
this.background(async () => { throw error; });
|
|
505
520
|
});
|
|
506
521
|
}
|
|
522
|
+
/** The reply to a send that names a model says which model and when it applies (orchestrator ledger `send-note`). */
|
|
523
|
+
async note(rid, model, effect) {
|
|
524
|
+
if (!this.ledgers.orch.entries().some(e => e.type === 'send-note' && e.rid === rid))
|
|
525
|
+
await this.ledgers.orch.append('send-note', { rid, model, effect });
|
|
526
|
+
}
|
|
507
527
|
ticket(st, entry) {
|
|
508
528
|
const spec = entry.spec, agent = st.wf.pins.agents.find(a => a.name === spec.agent);
|
|
509
529
|
if (!agent)
|
|
@@ -511,7 +531,8 @@ export class Engine {
|
|
|
511
531
|
return { wid: st.wf.wid, widRev: `${st.wf.wid}@${st.wf.revision}`, key: entry.key, gen: entry.gen,
|
|
512
532
|
callId: `${st.wf.wid}@${st.wf.revision}/${entry.key}@${entry.gen}`, spec, agent, workflowBudget: st.wf.pins.usageBudget, cwd: resolve(st.wf.cwd, spec.cwd ?? '.'), journal: st.wf.journal,
|
|
513
533
|
...(st.wf.pins.origin !== undefined ? { originSession: join(pinnedDir(this.ledgers.home, st.wf.wid), ...(st.wf.revision === 1 ? [] : [`r${st.wf.revision}`]), 'origin.jsonl') } : {}),
|
|
514
|
-
...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {})
|
|
534
|
+
...(entry.type === 'generation' ? { continueFrom: entry.from, opening: entry.opening } : {}),
|
|
535
|
+
...(entry.type === 'generation' && typeof entry.model === 'string' ? { model: entry.model } : {}) };
|
|
515
536
|
}
|
|
516
537
|
sealed(st, entry) {
|
|
517
538
|
if (entry.type === 'refused')
|