pi-durable-subagents 1.0.6 → 1.0.8
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 +29 -0
- package/README.md +5 -0
- package/dist/agent/main/snapshots.js +117 -42
- package/dist/agent/main.js +1 -1
- package/dist/cli/main.js +5 -2
- package/dist/orchestrator/config.js +151 -0
- package/dist/orchestrator/executor/index.js +10 -4
- package/dist/orchestrator/executor/observe.js +5 -3
- package/dist/orchestrator/main.js +21 -12
- package/dist/orchestrator/snapshot.js +292 -159
- package/dist/ui/data.js +42 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.8
|
|
4
|
+
|
|
5
|
+
- pi stays responsive with a long history: the extension's polling in pi's
|
|
6
|
+
main thread no longer re-reads it all. Workflow status folds only what a
|
|
7
|
+
journal appended, the orchestrator ledger is indexed once per change, the
|
|
8
|
+
attention check reads only this session's workflows, and an open question's
|
|
9
|
+
card reads only what the child session appended. On a home with 60
|
|
10
|
+
workflows and 356 calls, the dock refresh went from 4.5 ms to under 1 ms
|
|
11
|
+
every half second, and an idle pi's busy time from about 4% to 1.5%.
|
|
12
|
+
- Calls finished more than 10 s ago keep their summary only; their transcript
|
|
13
|
+
is read when shown. pi holds about 100 MB less and garbage collection no
|
|
14
|
+
longer pauses typing.
|
|
15
|
+
- A change of `config.json` written twice quickly to the same size is no
|
|
16
|
+
longer missed: the orchestrator compares the file's content.
|
|
17
|
+
|
|
18
|
+
## 1.0.7
|
|
19
|
+
|
|
20
|
+
- Changes to `config.json` (`defaultModel`, `pools`, `providers`, `memory`,
|
|
21
|
+
`k`) apply without restarting the orchestrator: it checks the file about
|
|
22
|
+
once a second and applies a valid change between slot admissions, so a call
|
|
23
|
+
still waiting for a slot follows the new model, pool or limit. Slots already
|
|
24
|
+
held are kept when a limit drops. An invalid change is refused and the
|
|
25
|
+
settings in effect stay; `status` names the error until the file is valid
|
|
26
|
+
again.
|
|
27
|
+
- `status` marks an asker whose execution hibernated with `hibernated: true`
|
|
28
|
+
(CLI: "hibernated, no slot"): it holds no provider slot until it is
|
|
29
|
+
answered. Both `status` forms list provider slots in use against their
|
|
30
|
+
limits, and the config in effect.
|
|
31
|
+
|
|
3
32
|
## 1.0.6
|
|
4
33
|
|
|
5
34
|
- A follow-up on a finished workflow shows as running work: in the dock, the
|
package/README.md
CHANGED
|
@@ -273,6 +273,11 @@ State lives in `~/.pi/durable-subagents`; set `DSA_HOME` to move it.
|
|
|
273
273
|
never stopped for memory.
|
|
274
274
|
- **onQuit:** `"pause"` (default) pauses a session's running workflows when
|
|
275
275
|
you quit that pi; `"continue"` lets them run on in the background.
|
|
276
|
+
- **Changes apply without a restart:** the orchestrator re-reads the file
|
|
277
|
+
when it changes. A new slot limit, pool or default model applies to the next
|
|
278
|
+
slot acquisition; slots already held are kept when a limit drops. An
|
|
279
|
+
invalid change is not applied, and `status` reports it next to the settings
|
|
280
|
+
still in effect (`config: <hash> since …`) and the slots held per provider.
|
|
276
281
|
|
|
277
282
|
## Switching back
|
|
278
283
|
|
|
@@ -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.js
CHANGED
|
@@ -325,7 +325,7 @@ export function registerMain(pi, ui) {
|
|
|
325
325
|
"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
326
|
"agents: list names, descriptions, default models and source for this cwd; use these names for run.",
|
|
327
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
|
-
"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) or failed, finished workflows one line each; 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.",
|
|
328
|
+
"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
329
|
"Control replies are {applied:true,rid} or {applied:false,reason,rid} when decided; otherwise {submitted:{rid}} after 10s.",
|
|
330
330
|
...(agents ? [`Available agents: ${agents}.`] : []),
|
|
331
331
|
"User sees a summary line above the editor; ↓ on an empty editor (or /subagents) opens the list, Enter watches live OR finished calls (finished transcripts remain on disk) and expands finished workflows. List keys: s steer (paste-capable input), x stop (confirm y), m model, a answer when asked, f follow-up on finished calls; action feedback appears in footer.",
|
package/dist/cli/main.js
CHANGED
|
@@ -65,13 +65,16 @@ 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.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.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)`);
|
|
72
72
|
if (view.olderFinished)
|
|
73
73
|
lines.push(`(+${view.olderFinished} older finished workflows; status <wid> shows one in detail)`);
|
|
74
|
-
|
|
74
|
+
const footer = [view.slots?.length ? `slots: ${view.slots.join(", ")}` : "", view.config ? `config: ${view.config}` : "", view.configRejected ? `config.json rejected: ${view.configRejected}` : ""].filter(Boolean);
|
|
75
|
+
if (!lines.length)
|
|
76
|
+
lines.push("No workflows");
|
|
77
|
+
return [...lines, ...footer].join("\n");
|
|
75
78
|
}
|
|
76
79
|
function snapshots(home, wid) {
|
|
77
80
|
if (!wid)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// $DSA_HOME/config.json for the orchestrator: read at start and again whenever the file changes, so a new slot limit,
|
|
2
|
+
// pool order or K-parameter needs no orchestrator restart (a restart at the wrong moment used to cost a dispatch).
|
|
3
|
+
// Orchestrator ledger entries: config{hash,config} — the settings in effect from then on (at start, or after a change);
|
|
4
|
+
// config-rejected{hash,error} — a changed file that was not applied; the settings before it stay in effect.
|
|
5
|
+
// A reload changes the shared config object in place: every later read sees it (the next slot acquisition, model
|
|
6
|
+
// resolution or check). Slots already held are kept when a limit drops; timers of running executions keep their period.
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { contentHash } from "../kernel/ids.js";
|
|
10
|
+
/** The keys the orchestrator reads; config.json also holds pi-side settings (ui, onQuit) that it ignores. */
|
|
11
|
+
const KEYS = ["defaultModel", "pools", "providers", "memory", "k"];
|
|
12
|
+
const K = ["lossBound", "checkpointMs", "stallMs", "progressMs", "switchTimeoutMs", "idleExitMs", "trackerMs", "hibernateMs", "spawnBudget"];
|
|
13
|
+
export const configPath = (home) => join(home, "config.json");
|
|
14
|
+
/** The orchestrator's part of a parsed config.json. */
|
|
15
|
+
export function orchestratorSettings(raw) {
|
|
16
|
+
const value = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
17
|
+
return Object.fromEntries(KEYS.filter(k => value[k] !== undefined).map(k => [k, value[k]]));
|
|
18
|
+
}
|
|
19
|
+
export const configHash = (config) => contentHash(orchestratorSettings(config)).slice(0, 12);
|
|
20
|
+
const record = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
21
|
+
const count = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
|
|
22
|
+
/** What is wrong with a parsed config.json for the orchestrator, or undefined. Unknown keys are allowed. */
|
|
23
|
+
export function configProblem(raw) {
|
|
24
|
+
if (!record(raw))
|
|
25
|
+
return "config.json must be a JSON object";
|
|
26
|
+
const { defaultModel, pools, providers, memory, k } = raw;
|
|
27
|
+
if (defaultModel !== undefined && typeof defaultModel !== "string")
|
|
28
|
+
return "defaultModel must be a string";
|
|
29
|
+
if (pools !== undefined) {
|
|
30
|
+
if (!record(pools))
|
|
31
|
+
return "pools must map names to model lists";
|
|
32
|
+
for (const [name, list] of Object.entries(pools))
|
|
33
|
+
if (!Array.isArray(list) || !list.length || !list.every(m => typeof m === "string" && m))
|
|
34
|
+
return `pools.${name} must be a nonempty list of "provider/id[:thinking]"`;
|
|
35
|
+
}
|
|
36
|
+
if (providers !== undefined) {
|
|
37
|
+
if (!record(providers))
|
|
38
|
+
return "providers must map provider names to { slots }";
|
|
39
|
+
for (const [name, p] of Object.entries(providers))
|
|
40
|
+
if (!record(p) || !Number.isSafeInteger(p.slots) || Number(p.slots) < 0)
|
|
41
|
+
return `providers.${name}.slots must be a nonnegative integer`;
|
|
42
|
+
}
|
|
43
|
+
if (memory !== undefined) {
|
|
44
|
+
if (!record(memory))
|
|
45
|
+
return "memory must be { reserveMb?, perChildMb? }";
|
|
46
|
+
for (const key of ["reserveMb", "perChildMb"])
|
|
47
|
+
if (memory[key] !== undefined && !count(memory[key]))
|
|
48
|
+
return `memory.${key} must be a nonnegative number`;
|
|
49
|
+
}
|
|
50
|
+
if (k !== undefined) {
|
|
51
|
+
if (!record(k))
|
|
52
|
+
return "k must map K-parameters to numbers";
|
|
53
|
+
for (const [key, v] of Object.entries(k)) {
|
|
54
|
+
if (!K.includes(key))
|
|
55
|
+
return `k.${key} is not a K-parameter (${K.join(", ")})`;
|
|
56
|
+
if (!count(v) || (key !== "lossBound" && key !== "spawnBudget" && v === 0))
|
|
57
|
+
return `k.${key} must be a positive number`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Parse config.json; a missing file is the empty config. A JSON error throws. */
|
|
62
|
+
export async function readConfig(path) {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT")
|
|
68
|
+
return {};
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Record the settings in effect unless the ledger already ends on the same ones (a restart with an unchanged file).
|
|
73
|
+
* After a rejection they are recorded again: that confirms the file is back to settings in effect. */
|
|
74
|
+
export async function recordConfig(orch, config) {
|
|
75
|
+
const hash = configHash(config), last = orch.entries().findLast(e => e.type === "config" || e.type === "config-rejected");
|
|
76
|
+
if (last?.type !== "config" || last.hash !== hash)
|
|
77
|
+
await orch.append("config", { hash, config: orchestratorSettings(config) });
|
|
78
|
+
return hash;
|
|
79
|
+
}
|
|
80
|
+
/** Replace the contents of the shared config object; readers hold the object, not a copy. */
|
|
81
|
+
function applyInPlace(target, next) {
|
|
82
|
+
const t = target;
|
|
83
|
+
for (const key of KEYS)
|
|
84
|
+
delete t[key];
|
|
85
|
+
Object.assign(t, orchestratorSettings(next));
|
|
86
|
+
}
|
|
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. */
|
|
90
|
+
export async function configStamp(path) {
|
|
91
|
+
try {
|
|
92
|
+
return `=${await readFile(path, "utf8")}`;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error.code === "ENOENT")
|
|
96
|
+
return "missing";
|
|
97
|
+
throw error;
|
|
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)); }
|
|
102
|
+
/** Watch config.json from the version `stamp` (read at start) and apply each valid change in place. */
|
|
103
|
+
/** `apply` runs the change where readers cannot observe half of it (the executor's admission section). */
|
|
104
|
+
export function watchConfig(options) {
|
|
105
|
+
const { path, config, orch } = options;
|
|
106
|
+
let stamp = options.stamp, running, rejected, stopped = false;
|
|
107
|
+
const once = async () => {
|
|
108
|
+
const now = await configStamp(path);
|
|
109
|
+
if (now === stamp || stopped)
|
|
110
|
+
return;
|
|
111
|
+
let raw;
|
|
112
|
+
// The content compared is the content applied: a second read could see another version than the stamp.
|
|
113
|
+
try {
|
|
114
|
+
raw = stampedConfig(now);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
// A half-written file reads as invalid JSON: retry on the next change of the file, report it once per version.
|
|
118
|
+
stamp = now;
|
|
119
|
+
const hash = `invalid:${now}`;
|
|
120
|
+
if (rejected !== hash) {
|
|
121
|
+
rejected = hash;
|
|
122
|
+
await orch.append("config-rejected", { error: `config.json is not valid JSON: ${error.message}` });
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
stamp = now;
|
|
127
|
+
// Validate the content first: `[]` or `null` project to no settings and must not pass as "unchanged".
|
|
128
|
+
const problem = configProblem(raw), next = orchestratorSettings(raw), hash = configHash(next);
|
|
129
|
+
if (problem) {
|
|
130
|
+
if (rejected !== hash) {
|
|
131
|
+
rejected = hash;
|
|
132
|
+
await orch.append("config-rejected", { hash, error: problem });
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
rejected = undefined;
|
|
137
|
+
let changed = false;
|
|
138
|
+
await (options.apply ?? (change => change()))(async () => {
|
|
139
|
+
changed = hash !== configHash(config);
|
|
140
|
+
if (changed)
|
|
141
|
+
applyInPlace(config, next);
|
|
142
|
+
await recordConfig(orch, config); // also confirms a return to the settings in effect after a rejection
|
|
143
|
+
});
|
|
144
|
+
if (changed)
|
|
145
|
+
options.onApplied?.();
|
|
146
|
+
};
|
|
147
|
+
const check = () => running ??= once().catch(error => console.error(`durable-subagents: config.json check failed: ${String(error)}`)).finally(() => { running = undefined; });
|
|
148
|
+
const timer = setInterval(check, options.intervalMs ?? 1000);
|
|
149
|
+
timer.unref();
|
|
150
|
+
return { stop: async () => { stopped = true; clearInterval(timer); await running; }, check };
|
|
151
|
+
}
|
|
@@ -389,13 +389,18 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
389
389
|
}
|
|
390
390
|
const stopped = (a) => a.stopped || entriesFor(a.ticket.journal, a.ticket.callId).some(e => e.type === "stop-intent");
|
|
391
391
|
const interrupted = (a) => closed || a.suspended || a.retired || stopped(a);
|
|
392
|
-
|
|
392
|
+
/** Wait for a slot. The candidates are decided again on every attempt, inside the serial section that also applies
|
|
393
|
+
* config.json changes: a queued call follows a reloaded defaultModel, pool or limit, never a mix of two versions. */
|
|
394
|
+
async function acquire(a, exec, decide) {
|
|
395
|
+
let continuation = false;
|
|
393
396
|
for (;;) {
|
|
394
397
|
let signal;
|
|
395
398
|
const changed = new Promise(resolve => { signal = resolve; waiters.add(resolve); });
|
|
396
399
|
const chosen = await serial(async () => {
|
|
397
400
|
if (interrupted(a) || await workflowReached(a.ticket))
|
|
398
401
|
return;
|
|
402
|
+
const decision = await decide(), models = decision.candidates, pool = decision.pool;
|
|
403
|
+
continuation = decision.continuation;
|
|
399
404
|
for (const model of models) {
|
|
400
405
|
if (!continuation && pool && skipped(pool, model))
|
|
401
406
|
continue;
|
|
@@ -434,7 +439,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
434
439
|
});
|
|
435
440
|
if (chosen || interrupted(a) || reached(totalUsage(a.ticket.journal.entries()), a.ticket.workflowBudget)) {
|
|
436
441
|
waiters.delete(signal);
|
|
437
|
-
return chosen;
|
|
442
|
+
return { model: chosen, continuation };
|
|
438
443
|
}
|
|
439
444
|
const timer = setTimeout(signal, config.k?.trackerMs ?? 1000);
|
|
440
445
|
try {
|
|
@@ -599,13 +604,13 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
599
604
|
}
|
|
600
605
|
let decision;
|
|
601
606
|
try {
|
|
602
|
-
decision = await launchModel(t, entries, previous);
|
|
607
|
+
decision = await acquire(a, exec, () => launchModel(t, entries, previous));
|
|
603
608
|
}
|
|
604
609
|
catch (error) {
|
|
605
610
|
await fence(journal, exec, { park: a });
|
|
606
611
|
return finish(journal, t.callId, exec, makeResult("failed", "", String(error)));
|
|
607
612
|
}
|
|
608
|
-
const model =
|
|
613
|
+
const model = decision.model;
|
|
609
614
|
if (!model || interrupted(a)) {
|
|
610
615
|
await fence(journal, exec, { park: a });
|
|
611
616
|
return finish(journal, t.callId, exec, !interrupted(a) ? makeResult("failed", "", "workflow budget reached") : makeResult("stopped"));
|
|
@@ -687,6 +692,7 @@ export default function createExecutor(ledgers, options = {}) {
|
|
|
687
692
|
await (await outbox).send(envelope.to, envelope.kind, envelope.body, envelope.cond, { rid: String(e.rid2) });
|
|
688
693
|
}
|
|
689
694
|
return {
|
|
695
|
+
async reconfigure(apply) { await serial(apply); wake(); },
|
|
690
696
|
run(ticket) {
|
|
691
697
|
const existing = completed.get(ticket.callId);
|
|
692
698
|
if (existing)
|
|
@@ -123,7 +123,7 @@ export async function observeExecution(d) {
|
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
-
let scanning = false,
|
|
126
|
+
let scanning = false, failingSince;
|
|
127
127
|
const scan = () => {
|
|
128
128
|
if (scanning)
|
|
129
129
|
return;
|
|
@@ -133,12 +133,14 @@ export async function observeExecution(d) {
|
|
|
133
133
|
// One failed process-table scan is no evidence, not a reason to fence a live child: on macOS `ps` can miss its
|
|
134
134
|
// 2 s deadline right after the orchestrator was stopped or the machine slept (seen on CI as a spurious loss and
|
|
135
135
|
// a rerun). Only a scan that keeps failing for a minute ends the observation.
|
|
136
|
+
// Measured in elapsed time: the scan period is fixed at start while k.trackerMs can be reloaded.
|
|
136
137
|
try {
|
|
137
138
|
clock.scan(await d.track());
|
|
138
|
-
|
|
139
|
+
failingSince = undefined;
|
|
139
140
|
}
|
|
140
141
|
catch (error) {
|
|
141
|
-
|
|
142
|
+
failingSince ??= performance.now();
|
|
143
|
+
if (performance.now() - failingSince >= 60_000)
|
|
142
144
|
throw error;
|
|
143
145
|
}
|
|
144
146
|
const nextSize = (await fileStat(session).catch(() => ({ size: 0 }))).size;
|
|
@@ -6,7 +6,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
6
6
|
}
|
|
7
7
|
return path;
|
|
8
8
|
};
|
|
9
|
-
import { mkdir
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
import { realpathSync } from 'node:fs';
|
|
12
12
|
import { join, resolve } from 'node:path';
|
|
@@ -14,6 +14,7 @@ import { dsaHome, orchLedger, orchLock } from "../paths.js";
|
|
|
14
14
|
import { openJournal } from "../kernel/journal.js";
|
|
15
15
|
import { OsLock } from "../platform/lock.js";
|
|
16
16
|
import { Engine } from "./engine.js";
|
|
17
|
+
import { configPath, configProblem, configStamp, recordConfig, stampedConfig, watchConfig } from "./config.js";
|
|
17
18
|
/** P2, C4, K6: Acquire the single authority before opening ledgers, recover, and idle-exit. */
|
|
18
19
|
export async function main(options = {}) {
|
|
19
20
|
const home = options.home ?? dsaHome();
|
|
@@ -21,27 +22,35 @@ export async function main(options = {}) {
|
|
|
21
22
|
const lock = await new OsLock().tryAcquire(orchLock(home));
|
|
22
23
|
if (!lock)
|
|
23
24
|
return;
|
|
24
|
-
let engine, ledgers;
|
|
25
|
+
let engine, ledgers, watcher;
|
|
25
26
|
try {
|
|
26
|
-
let config = options.config;
|
|
27
|
+
let config = options.config, stamp;
|
|
27
28
|
if (!config) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
config
|
|
35
|
-
|
|
29
|
+
// One read gives both the version and the settings: with two, a file changed and changed back in between kept
|
|
30
|
+
// the second version in effect while the watcher saw no change.
|
|
31
|
+
stamp = await configStamp(configPath(home));
|
|
32
|
+
const raw = stampedConfig(stamp);
|
|
33
|
+
const problem = configProblem(raw);
|
|
34
|
+
if (problem)
|
|
35
|
+
console.error(`durable-subagents: config.json: ${problem}`);
|
|
36
|
+
config = raw;
|
|
36
37
|
}
|
|
37
38
|
ledgers = { home, config, orch: await openJournal(orchLedger(home)) };
|
|
38
39
|
const factory = options.executor ?? (await import(__rewriteRelativeImportExtension(new URL(import.meta.url.endsWith('.ts') ? './executor/index.ts' : './executor/index.js', import.meta.url).href))).default;
|
|
39
|
-
|
|
40
|
+
const executor = factory(ledgers);
|
|
41
|
+
engine = new Engine(ledgers, executor, options);
|
|
42
|
+
if (stamp !== undefined) {
|
|
43
|
+
await recordConfig(ledgers.orch, config);
|
|
44
|
+
// A change applies between slot admissions, so one admission never mixes two versions of the limits.
|
|
45
|
+
watcher = watchConfig({ path: configPath(home), stamp, config, orch: ledgers.orch, intervalMs: Math.min(1000, config.k?.trackerMs ?? 1000),
|
|
46
|
+
apply: change => executor.reconfigure ? executor.reconfigure(change) : change() });
|
|
47
|
+
}
|
|
40
48
|
await engine.recover();
|
|
41
49
|
await engine.loop(options.signal);
|
|
42
50
|
}
|
|
43
51
|
finally {
|
|
44
52
|
try {
|
|
53
|
+
await watcher?.stop();
|
|
45
54
|
await engine?.close();
|
|
46
55
|
}
|
|
47
56
|
finally {
|
|
@@ -67,16 +67,16 @@ function callKey(call) {
|
|
|
67
67
|
export function refusedResult(key, reason) {
|
|
68
68
|
return { key, gen: 0, status: "failed", ok: false, error: reason === "spawn-budget" ? "spawn budget exceeded" : String(reason), output: "" };
|
|
69
69
|
}
|
|
70
|
-
|
|
70
|
+
function snapshotReducer(wid, entries) {
|
|
71
71
|
const created = entries.find(e => e.type === "wf-created");
|
|
72
72
|
const rev = Math.max(1, ...entries.filter(e => e.type === "wf-created" || e.type === "revised").map(e => Number(e.revision) || 1));
|
|
73
73
|
const boundary = entries.findLastIndex(e => e.type === "revised");
|
|
74
74
|
const current = entries.slice(Math.max(0, boundary));
|
|
75
|
-
|
|
76
|
-
const done = terminal?.type === JT.done ? terminal : undefined;
|
|
75
|
+
let terminal = current.findLast(e => e.type === JT.done || (e.type === "resumed" && !e.call));
|
|
77
76
|
const calls = new Map();
|
|
78
77
|
const byExec = new Map();
|
|
79
|
-
const
|
|
78
|
+
const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
|
|
79
|
+
const resolved = new Set();
|
|
80
80
|
const generations = new Set(), retired = new Set();
|
|
81
81
|
const attention = [];
|
|
82
82
|
// P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
|
|
@@ -84,169 +84,270 @@ export function snapshotFromEntries(wid, entries) {
|
|
|
84
84
|
const tools = new Map();
|
|
85
85
|
// P7, P27: forward / forward-delivered / forward-retired, by destination call; retirements carry only rid2.
|
|
86
86
|
const sends = new Map(), byRid2 = new Map();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
|
|
121
|
-
const call = byExec.get(String(e.exec));
|
|
122
|
-
if (call && call.phase === "running")
|
|
87
|
+
function apply(batch, from = 0) {
|
|
88
|
+
for (let i = from; i < batch.length; i++) {
|
|
89
|
+
const e = batch[i];
|
|
90
|
+
if (i >= boundary && (e.type === JT.done || (e.type === "resumed" && !e.call)))
|
|
91
|
+
terminal = e;
|
|
92
|
+
if (e.type === JT.attentionResolved)
|
|
93
|
+
resolved.add(`${e.id}@${e.rev}`);
|
|
94
|
+
if (["call", "generation", "refused", "reused"].includes(e.type)) {
|
|
95
|
+
if (boundary >= 0 && e.seq < entries[boundary].seq)
|
|
96
|
+
continue;
|
|
97
|
+
const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
|
|
98
|
+
const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
|
|
99
|
+
if (e.type === "generation")
|
|
100
|
+
generations.add(callId);
|
|
101
|
+
const result = e.type === "refused" ? refusedResult(key, e.reason) :
|
|
102
|
+
e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
|
|
103
|
+
const wanted = e.spec?.model;
|
|
104
|
+
calls.set(callId, { key, gen, callId, pos: Number(e.pos), agent: String(e.spec?.agent ?? ""),
|
|
105
|
+
// Until a slot is acquired (`selected`), show the model the call asked for, not nothing.
|
|
106
|
+
...(typeof wanted === "string" && wanted ? { model: wanted } : {}),
|
|
107
|
+
phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
|
|
108
|
+
...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
|
|
109
|
+
}
|
|
110
|
+
else if (e.type === "retired") {
|
|
111
|
+
retired.add(String(e.call));
|
|
112
|
+
}
|
|
113
|
+
else if (e.type === JT.exec) {
|
|
114
|
+
const call = calls.get(String(e.call));
|
|
115
|
+
if (!call)
|
|
116
|
+
continue;
|
|
117
|
+
// An execution waits for a provider slot and memory before it launches; it is running once `selected` says so
|
|
118
|
+
// (otherwise a call still waiting for a slot reads "thinking · 2m").
|
|
119
|
+
call.exec = String(e.exec);
|
|
123
120
|
call.phase = "queued";
|
|
124
|
-
|
|
125
|
-
else if (e.type === "selected") {
|
|
126
|
-
const call = byExec.get(String(e.exec)), m = e.model;
|
|
127
|
-
if (call && m)
|
|
128
|
-
call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
129
|
-
if (call && call.phase === "queued")
|
|
130
|
-
call.phase = "running";
|
|
131
|
-
}
|
|
132
|
-
else if (e.type === "observation") {
|
|
133
|
-
// Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
|
|
134
|
-
const call = byExec.get(String(e.exec));
|
|
135
|
-
if (call)
|
|
121
|
+
call.startedAt ??= e.ts;
|
|
136
122
|
call.lastActivity = e.ts;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
123
|
+
byExec.set(call.exec, call);
|
|
124
|
+
delete call.hibernated;
|
|
125
|
+
hibernating.delete(call.callId);
|
|
126
|
+
}
|
|
127
|
+
else if (e.type === "hibernated") {
|
|
128
|
+
// The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
|
|
129
|
+
const call = calls.get(String(e.call));
|
|
130
|
+
if (call?.exec && call.exec === e.exec)
|
|
131
|
+
hibernating.set(call.callId, call.exec);
|
|
132
|
+
}
|
|
133
|
+
else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
|
|
134
|
+
const call = calls.get(String(e.call));
|
|
135
|
+
if (call) {
|
|
136
|
+
delete call.hibernated;
|
|
137
|
+
hibernating.delete(call.callId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
else if (e.type === JT.fenced) {
|
|
141
|
+
// A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
|
|
142
|
+
const call = byExec.get(String(e.exec));
|
|
143
|
+
if (call && call.phase === "running")
|
|
144
|
+
call.phase = "queued";
|
|
145
|
+
if (call && hibernating.get(call.callId) === e.exec)
|
|
146
|
+
call.hibernated = true;
|
|
147
|
+
}
|
|
148
|
+
else if (e.type === "selected") {
|
|
149
|
+
const call = byExec.get(String(e.exec)), m = e.model;
|
|
150
|
+
if (call && m)
|
|
151
|
+
call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
152
|
+
if (call && call.phase === "queued")
|
|
153
|
+
call.phase = "running";
|
|
154
|
+
}
|
|
155
|
+
else if (e.type === "observation") {
|
|
156
|
+
// Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
|
|
157
|
+
const call = byExec.get(String(e.exec));
|
|
158
|
+
if (call)
|
|
159
|
+
call.lastActivity = e.ts;
|
|
160
|
+
if (call && e.type === "observation" && e.event?.type === "tool_execution_start")
|
|
161
|
+
tools.set(call.callId, (tools.get(call.callId) ?? 0) + 1);
|
|
162
|
+
}
|
|
163
|
+
else if (e.type === "usage") {
|
|
164
|
+
const id = `${e.call}:${e.id}`, u = e.usage;
|
|
165
|
+
if (seen.has(id) || !u)
|
|
166
|
+
continue;
|
|
167
|
+
seen.add(id);
|
|
168
|
+
const total = live.get(String(e.call)) ?? zero();
|
|
169
|
+
total.input += u.input;
|
|
170
|
+
total.output += u.output;
|
|
171
|
+
total.costUsd += u.costUsd;
|
|
172
|
+
live.set(String(e.call), total);
|
|
173
|
+
}
|
|
174
|
+
else if (e.type === JT.sealed) {
|
|
175
|
+
const usage = e.result?.usage;
|
|
176
|
+
if (usage)
|
|
177
|
+
sealedUsage.set(String(e.call), usage);
|
|
178
|
+
const call = calls.get(String(e.call));
|
|
179
|
+
if (!call)
|
|
180
|
+
continue;
|
|
181
|
+
call.phase = "sealed";
|
|
182
|
+
call.result = e.result;
|
|
183
|
+
call.endedAt = e.ts;
|
|
184
|
+
delete call.hibernated;
|
|
185
|
+
hibernating.delete(call.callId);
|
|
186
|
+
}
|
|
187
|
+
else if (e.type === JT.attention) {
|
|
188
|
+
const item = e.item;
|
|
165
189
|
attention.push(item);
|
|
190
|
+
}
|
|
191
|
+
else if (e.type === "forward") {
|
|
192
|
+
const envelope = e.envelope;
|
|
193
|
+
const send = { rid: String(e.rid), kind: String(envelope?.kind ?? ""), state: "pending", at: e.ts,
|
|
194
|
+
...(envelope?.kind === "model" && envelope.body?.provider ? { model: `${envelope.body.provider}/${envelope.body.model}` } : {}) };
|
|
195
|
+
const list = sends.get(String(e.dest)) ?? [];
|
|
196
|
+
list.push(send);
|
|
197
|
+
sends.set(String(e.dest), list);
|
|
198
|
+
byRid2.set(`${e.dest}\n${e.rid2}`, send);
|
|
199
|
+
byRid2.set(String(e.rid2), send);
|
|
200
|
+
}
|
|
201
|
+
else if (e.type === "forward-delivered" || e.type === "forward-retired") {
|
|
202
|
+
const send = byRid2.get(e.type === "forward-delivered" ? `${e.call}\n${e.rid2}` : String(e.rid2));
|
|
203
|
+
if (!send || send.state !== "pending")
|
|
204
|
+
continue;
|
|
205
|
+
send.state = e.type === "forward-delivered" ? "delivered" : "retired";
|
|
206
|
+
send.at = e.ts;
|
|
207
|
+
if (e.type === "forward-delivered" && e.reason !== undefined)
|
|
208
|
+
send.reason = String(e.reason);
|
|
209
|
+
}
|
|
166
210
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
}
|
|
212
|
+
apply(entries);
|
|
213
|
+
function finish() {
|
|
214
|
+
const done = terminal?.type === JT.done ? terminal : undefined;
|
|
215
|
+
// Finish owns every exposed object; UI decoration and consumers cannot alter reducer history.
|
|
216
|
+
const list = structuredClone([...calls.values()]);
|
|
217
|
+
const openAttention = structuredClone(attention.filter(item => !resolved.has(`${item.id}@${item.rev}`)));
|
|
218
|
+
for (const item of openAttention) {
|
|
219
|
+
const call = item.kind === "question" ? list.find(c => item.id.startsWith(`q:${c.callId}:`)) : undefined;
|
|
220
|
+
if (call && (call.phase === "running" || call.phase === "queued"))
|
|
221
|
+
call.phase = "asking"; // a hibernated asker is fenced
|
|
176
222
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
223
|
+
for (const c of list)
|
|
224
|
+
if (c.hibernated && c.phase !== "asking")
|
|
225
|
+
delete c.hibernated;
|
|
226
|
+
const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
|
|
227
|
+
const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
|
|
228
|
+
for (const c of list) {
|
|
229
|
+
counts[c.phase]++;
|
|
230
|
+
const usage = usageOf(c.callId);
|
|
231
|
+
if (usage)
|
|
232
|
+
c.usage = { ...usage };
|
|
233
|
+
const n = tools.get(c.callId);
|
|
234
|
+
if (n)
|
|
235
|
+
c.tools = n;
|
|
236
|
+
const forwarded = sends.get(c.callId);
|
|
237
|
+
if (forwarded) {
|
|
238
|
+
c.sends = structuredClone(forwarded);
|
|
239
|
+
const pending = forwarded.filter(pendingMessage).length;
|
|
240
|
+
if (pending)
|
|
241
|
+
c.pending = pending;
|
|
242
|
+
const switching = c.phase !== "sealed" ? forwarded.findLast(s => s.kind === "model" && s.state === "pending")?.model : undefined;
|
|
243
|
+
if (switching && switching !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
|
|
244
|
+
c.switching = switching;
|
|
245
|
+
}
|
|
185
246
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
counts[c.phase]++;
|
|
197
|
-
const usage = usageOf(c.callId);
|
|
198
|
-
if (usage)
|
|
199
|
-
c.usage = { ...usage };
|
|
200
|
-
const n = tools.get(c.callId);
|
|
201
|
-
if (n)
|
|
202
|
-
c.tools = n;
|
|
203
|
-
const forwarded = sends.get(c.callId);
|
|
204
|
-
if (forwarded) {
|
|
205
|
-
c.sends = forwarded;
|
|
206
|
-
const pending = forwarded.filter(pendingMessage).length;
|
|
207
|
-
if (pending)
|
|
208
|
-
c.pending = pending;
|
|
209
|
-
const switching = c.phase !== "sealed" ? forwarded.findLast(s => s.kind === "model" && s.state === "pending")?.model : undefined;
|
|
210
|
-
if (switching && switching !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
|
|
211
|
-
c.switching = switching;
|
|
247
|
+
const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
|
|
248
|
+
for (const c of after)
|
|
249
|
+
c.afterEnd = true;
|
|
250
|
+
const followUps = after.length;
|
|
251
|
+
const usage = zero();
|
|
252
|
+
for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
|
|
253
|
+
const u = usageOf(id);
|
|
254
|
+
usage.input += u.input;
|
|
255
|
+
usage.output += u.output;
|
|
256
|
+
usage.costUsd += u.costUsd;
|
|
212
257
|
}
|
|
258
|
+
return {
|
|
259
|
+
wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
|
|
260
|
+
...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
|
|
261
|
+
status: done ? done.status : "running",
|
|
262
|
+
...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: structuredClone(done.result) } : {}),
|
|
263
|
+
...(done ? { endedAt: done.ts } : {}),
|
|
264
|
+
...(followUps ? { followUps } : {}),
|
|
265
|
+
calls: list, counts, attention: openAttention, usage,
|
|
266
|
+
};
|
|
213
267
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
|
|
220
|
-
const u = usageOf(id);
|
|
221
|
-
usage.input += u.input;
|
|
222
|
-
usage.output += u.output;
|
|
223
|
-
usage.costUsd += u.costUsd;
|
|
224
|
-
}
|
|
225
|
-
return {
|
|
226
|
-
wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
|
|
227
|
-
...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
|
|
228
|
-
status: done ? done.status : "running",
|
|
229
|
-
...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
|
|
230
|
-
...(done ? { endedAt: done.ts } : {}),
|
|
231
|
-
...(followUps ? { followUps } : {}),
|
|
232
|
-
calls: list, counts, attention, usage,
|
|
233
|
-
};
|
|
268
|
+
// Only the current revision's reuse looks ahead for a seal (entries before the boundary are skipped).
|
|
269
|
+
return { apply, finish, hasReuse: current.some(e => e.type === "reused") };
|
|
270
|
+
}
|
|
271
|
+
export function snapshotFromEntries(wid, entries) {
|
|
272
|
+
return snapshotReducer(wid, entries).finish();
|
|
234
273
|
}
|
|
235
274
|
/** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
|
|
236
275
|
// A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
|
|
237
276
|
// re-deriving every historical workflow on each UI refresh dominated pi's main thread.
|
|
277
|
+
const FOLDS = 256; // workflows whose fold state is kept; pruned and long-idle ones fall out
|
|
278
|
+
const folds = new Map();
|
|
238
279
|
const derived = new WeakMap();
|
|
239
280
|
export function workflowSnapshot(home, wid) {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
if (hit?.wid
|
|
243
|
-
|
|
281
|
+
const path = journalPath(home, wid), entries = readJournalSnapshot(path), hit = derived.get(entries);
|
|
282
|
+
let wf;
|
|
283
|
+
if (hit?.wid === wid)
|
|
284
|
+
wf = hit.snapshot;
|
|
285
|
+
else {
|
|
286
|
+
const prior = folds.get(path);
|
|
287
|
+
// The journal reader preserves entry identity only when extending the same committed prefix.
|
|
288
|
+
const prefix = prior?.wid === wid && entries.length >= prior.length &&
|
|
289
|
+
(prior.length === 0 || entries[prior.length - 1] === prior.last);
|
|
290
|
+
let reusable = prefix;
|
|
291
|
+
if (reusable)
|
|
292
|
+
for (let i = prior.length; i < entries.length; i++) {
|
|
293
|
+
const e = entries[i];
|
|
294
|
+
// Revisions change earlier call identities/boundaries. Reuse looks ahead for the first seal.
|
|
295
|
+
if (e.type === "revised" || e.type === "wf-created" || e.type === "reused" ||
|
|
296
|
+
(prior.reducer.hasReuse && e.type === JT.sealed)) {
|
|
297
|
+
reusable = false;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const reducer = reusable ? prior.reducer : snapshotReducer(wid, entries);
|
|
302
|
+
if (reusable)
|
|
303
|
+
reducer.apply(entries, prior.length);
|
|
304
|
+
wf = reducer.finish();
|
|
305
|
+
if (entries.length) {
|
|
306
|
+
folds.delete(path);
|
|
307
|
+
folds.set(path, { wid, length: entries.length, last: entries.at(-1), reducer }); // least recently folded first
|
|
308
|
+
if (folds.size > FOLDS)
|
|
309
|
+
folds.delete(folds.keys().next().value);
|
|
310
|
+
derived.set(entries, { wid, snapshot: wf });
|
|
311
|
+
}
|
|
312
|
+
else
|
|
313
|
+
folds.delete(path);
|
|
314
|
+
}
|
|
244
315
|
const planned = plannedTotal(home, wid, wf.rev);
|
|
245
316
|
return planned === undefined ? wf : { ...wf, planned };
|
|
246
317
|
}
|
|
247
318
|
/** Ops: wids archived by `prune` (orchestrator ledger `pruned{wid}`); they no longer exist for any reader. */
|
|
248
319
|
function prunedIds(home) {
|
|
249
|
-
return
|
|
320
|
+
return ledgerIndex(readJournalSnapshot(orchLedger(home))).pruned;
|
|
321
|
+
}
|
|
322
|
+
const ledgerIndexes = new WeakMap();
|
|
323
|
+
export function ledgerIndex(ledger) {
|
|
324
|
+
let ix = ledgerIndexes.get(ledger);
|
|
325
|
+
if (!ix || ix.length > ledger.length || (ix.length && ledger[ix.length - 1] !== ix.last)) {
|
|
326
|
+
ix = { length: 0, created: new Map(), pruned: new Set(), byWid: new Map(), byOrigin: new Map() };
|
|
327
|
+
ledgerIndexes.set(ledger, ix);
|
|
328
|
+
}
|
|
329
|
+
for (let i = ix.length; i < ledger.length; i++) {
|
|
330
|
+
const e = ledger[i];
|
|
331
|
+
if (e.type === JT.created) {
|
|
332
|
+
if (!ix.created.has(e.wid))
|
|
333
|
+
ix.created.set(e.wid, e);
|
|
334
|
+
}
|
|
335
|
+
else if (e.type === "pruned")
|
|
336
|
+
ix.pruned.add(String(e.wid));
|
|
337
|
+
else if (e.type === "drain" || e.type === "undrain") {
|
|
338
|
+
if (e.type === "drain")
|
|
339
|
+
ix.lastDrain = i;
|
|
340
|
+
if (e.wid === undefined && e.origin === undefined)
|
|
341
|
+
ix.global = i;
|
|
342
|
+
if (e.wid !== undefined)
|
|
343
|
+
ix.byWid.set(e.wid, i);
|
|
344
|
+
if (e.origin !== undefined)
|
|
345
|
+
ix.byOrigin.set(e.origin, i);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
ix.length = ledger.length;
|
|
349
|
+
ix.last = ledger[ledger.length - 1];
|
|
350
|
+
return ix;
|
|
250
351
|
}
|
|
251
352
|
function workflowIds(home) {
|
|
252
353
|
let wids = [];
|
|
@@ -263,19 +364,21 @@ function workflowIds(home) {
|
|
|
263
364
|
/** Drain: the one hold rule. drain/undrain entries apply to every workflow (no scope), one session's (`origin`) or one
|
|
264
365
|
* workflow (`wid`). A workflow is held when the newest entry that applies to it is a drain recorded after it was created. */
|
|
265
366
|
export function holdOf(ledger, wid, origin) {
|
|
266
|
-
|
|
267
|
-
const
|
|
367
|
+
// The newest applicable entry is the newest of the newest unscoped, the newest for this wid and the newest for its origin.
|
|
368
|
+
const ix = ledgerIndex(ledger);
|
|
369
|
+
const at = Math.max(ix.global ?? -1, ix.byWid.get(wid) ?? -1, origin !== undefined ? ix.byOrigin.get(origin) ?? -1 : -1);
|
|
370
|
+
const last = at >= 0 ? ledger[at] : undefined;
|
|
268
371
|
if (last?.type !== "drain")
|
|
269
372
|
return undefined;
|
|
270
|
-
const created =
|
|
373
|
+
const created = ix.created.get(wid);
|
|
271
374
|
return !created || created.seq < last.seq ? last : undefined;
|
|
272
375
|
}
|
|
273
376
|
/** Drain: the workflows a drain (stop-all, a quit pi) holds until resume, and since when. */
|
|
274
377
|
export function heldWorkflows(home) {
|
|
275
|
-
const ledger = readJournalSnapshot(orchLedger(home));
|
|
276
|
-
const origin = (wid) =>
|
|
378
|
+
const ledger = readJournalSnapshot(orchLedger(home)), ix = ledgerIndex(ledger);
|
|
379
|
+
const origin = (wid) => ix.created.get(wid)?.origin;
|
|
277
380
|
const hold = (wid) => holdOf(ledger, wid, origin(wid));
|
|
278
|
-
const since =
|
|
381
|
+
const since = ix.lastDrain !== undefined ? ledger[ix.lastDrain].ts : undefined;
|
|
279
382
|
return { ...(since !== undefined ? { since } : {}), held: wid => hold(wid) !== undefined };
|
|
280
383
|
}
|
|
281
384
|
/** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
|
|
@@ -302,7 +405,7 @@ export function compactWorkflow(wf) {
|
|
|
302
405
|
const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
|
|
303
406
|
return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
|
|
304
407
|
...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
|
|
305
|
-
...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}) };
|
|
408
|
+
...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
|
|
306
409
|
}),
|
|
307
410
|
attention: wf.attention.map(a => ({ id: a.id, rev: a.rev, kind: a.kind, text: clip(a.text, 300), ...(a.call ? { call: a.call } : {}), ...(a.qid ? { qid: a.qid } : {}) })),
|
|
308
411
|
...(wf.paused ? { paused: true } : {}), ...(wf.followUps ? { followUps: wf.followUps } : {}),
|
|
@@ -338,7 +441,8 @@ export function statusView(home, options = {}) {
|
|
|
338
441
|
const hidden = all.length - shown.length;
|
|
339
442
|
const paused = all.filter(w => w.paused).length;
|
|
340
443
|
return { workflows: shown.map(compactWorkflow), ...(hidden ? { olderFinished: hidden, hint: "status wid=<wid> shows any workflow in detail" } : {}),
|
|
341
|
-
...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {})
|
|
444
|
+
...(paused ? { paused: `${paused} workflow${paused > 1 ? "s" : ""} paused (stop-all, drain or a quit pi) since ${new Date(since).toISOString()}; resume continues them (new runs are not affected)` } : {}),
|
|
445
|
+
...slotsView(home) };
|
|
342
446
|
}
|
|
343
447
|
const FINAL = ["done", "failed", "stopped"];
|
|
344
448
|
/** Finished with nothing left running (a follow-up on a finished workflow is live work). */
|
|
@@ -347,6 +451,33 @@ const age = (ms) => ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : ms
|
|
|
347
451
|
const tokens = (u) => { const n = u ? u.input + u.output : 0; return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}K` : String(n); };
|
|
348
452
|
/** The latest generation of every key, in first-call order. */
|
|
349
453
|
const latestCalls = (wf) => [...new Map(wf.calls.map(c => [c.key, c])).values()];
|
|
454
|
+
/** Provider slots from the orchestrator ledger: holders per provider (hold/release{pool,slot,exec}) and the limits of
|
|
455
|
+
* the settings in effect (the latest config{hash,config}); config-rejected after it is reported too. */
|
|
456
|
+
export function slotsView(home, now = Date.now()) {
|
|
457
|
+
const held = new Map();
|
|
458
|
+
let config, rejected;
|
|
459
|
+
for (const e of readJournalSnapshot(orchLedger(home))) {
|
|
460
|
+
if (e.type === "hold")
|
|
461
|
+
held.set(`${e.pool}:${e.slot}`, e);
|
|
462
|
+
else if (e.type === "release" && held.get(`${e.pool}:${e.slot}`)?.exec === e.exec)
|
|
463
|
+
held.delete(`${e.pool}:${e.slot}`);
|
|
464
|
+
else if (e.type === "config") {
|
|
465
|
+
config = e;
|
|
466
|
+
rejected = undefined;
|
|
467
|
+
}
|
|
468
|
+
else if (e.type === "config-rejected")
|
|
469
|
+
rejected = e;
|
|
470
|
+
}
|
|
471
|
+
const limits = (config?.config?.providers) ?? {};
|
|
472
|
+
const holders = new Map();
|
|
473
|
+
for (const e of held.values())
|
|
474
|
+
if (e.pool !== "memory")
|
|
475
|
+
holders.set(String(e.pool), (holders.get(String(e.pool)) ?? 0) + 1);
|
|
476
|
+
const names = [...new Set([...Object.keys(limits), ...holders.keys()])].sort();
|
|
477
|
+
const slots = names.map(p => { const n = holders.get(p) ?? 0, limit = limits[p]?.slots; return typeof limit === "number" ? `${p} ${n}/${limit}` : `${p} ${n} (no limit)`; });
|
|
478
|
+
return { ...(slots.length ? { slots } : {}), ...(config ? { config: `${String(config.hash)} since ${age(now - config.ts)} ago` } : {}),
|
|
479
|
+
...(rejected ? { configRejected: `${clip(String(rejected.error), 200)} (${age(now - rejected.ts)} ago); ${config ? String(config.hash) : "the start settings"} stay in effect` } : {}) };
|
|
480
|
+
}
|
|
350
481
|
/** Tool status without a wid: what runs, what waits for an answer and what failed, with finished workflows one line each.
|
|
351
482
|
* The full view (every call's last line and usage) ran to tens of thousands of tokens on a busy home. */
|
|
352
483
|
export function statusBrief(home, options = {}) {
|
|
@@ -366,9 +497,11 @@ export function statusBrief(home, options = {}) {
|
|
|
366
497
|
...(live && c.startedAt !== undefined ? { for: age(now - c.startedAt) } : {}),
|
|
367
498
|
...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
|
|
368
499
|
...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
|
|
369
|
-
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {})
|
|
500
|
+
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
|
|
501
|
+
...(c.hibernated ? { hibernated: true } : {}) };
|
|
370
502
|
});
|
|
371
|
-
const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
|
|
503
|
+
const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
|
|
504
|
+
...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
|
|
372
505
|
const alerts = open.filter(a => a.kind !== "question").map(a => `${a.kind}${a.call ? ` ${w.wid}/${callKey(a.call)}` : ""}: ${clip(a.text, 200)}`);
|
|
373
506
|
return { wid: w.wid, ...(w.name ? { name: w.name } : {}), status: w.status, ...(w.paused ? { paused: true } : {}), ...(w.followUps ? { followUps: w.followUps } : {}),
|
|
374
507
|
progress: `${p.done}/${p.total}${p.plus ? "+" : ""}`, tokens: tokens(w.usage), calls,
|
|
@@ -383,7 +516,7 @@ export function statusBrief(home, options = {}) {
|
|
|
383
516
|
return {
|
|
384
517
|
active: active.filter(mine).map(brief), ...(others.length ? { otherSessions: others.slice(0, 10).map(line) } : {}),
|
|
385
518
|
finished: finished.slice(0, keep).map(line), ...(finished.length > keep ? { olderFinished: finished.length - keep } : {}),
|
|
386
|
-
...(paused ? { paused } : {}),
|
|
519
|
+
...(paused ? { paused } : {}), ...slotsView(home, now),
|
|
387
520
|
hint: "status wid=<wid> shows one workflow (outputs clipped); add key=<key> for one call's full result, or full:true for everything",
|
|
388
521
|
};
|
|
389
522
|
}
|
package/dist/ui/data.js
CHANGED
|
@@ -44,6 +44,10 @@ export class LazyMap extends Map {
|
|
|
44
44
|
this.produce(key); return super.get(key); }
|
|
45
45
|
has(key) { return this.get(key) !== undefined; }
|
|
46
46
|
}
|
|
47
|
+
/** How long after its seal a call's session is still followed. A sealed execution has been fenced, so its session is
|
|
48
|
+
* final: a follow-up or a revision runs as a new generation with its own session. A session changed after that (by
|
|
49
|
+
* hand) shows in a new pi session only. */
|
|
50
|
+
const FINAL_MS = 10_000;
|
|
47
51
|
/** A1, P25: Read the UI's data from durable workflow snapshots and native session tails only. */
|
|
48
52
|
export class UiData {
|
|
49
53
|
workflows = [];
|
|
@@ -56,8 +60,21 @@ export class UiData {
|
|
|
56
60
|
/** Where the dock sits: below the editor (default; never splits an editor header such as a powerline bar) or above. */
|
|
57
61
|
dockAt = "above";
|
|
58
62
|
/** Per-refresh budget (ms) for reading not yet read finished sessions (history) in the background. */
|
|
59
|
-
warmMs =
|
|
63
|
+
warmMs = 8;
|
|
60
64
|
tails = new Map();
|
|
65
|
+
/** Facts of calls sealed more than FINAL_MS ago, read once after their seal; their transcript is read when shown. */
|
|
66
|
+
final = new Map();
|
|
67
|
+
/** The transcript of one final call last asked for (the open call view asks on every frame). Keeping every
|
|
68
|
+
* historical transcript parsed held hundreds of MB and made pi's garbage collection pause typing. */
|
|
69
|
+
opened;
|
|
70
|
+
transcript(callId, sessions) {
|
|
71
|
+
const final = this.final.get(callId);
|
|
72
|
+
if (!final)
|
|
73
|
+
return;
|
|
74
|
+
if (this.opened?.callId !== callId || this.opened.endedAt !== final.endedAt)
|
|
75
|
+
this.opened = { callId, endedAt: final.endedAt, entries: sessionBranch(new SessionTail().read(final.path)) };
|
|
76
|
+
sessions.put(callId, this.opened.entries);
|
|
77
|
+
}
|
|
61
78
|
/** Branch and facts per call, reused while its session tail is unchanged: a finished call's session never grows, and
|
|
62
79
|
* re-deriving every historical call on each refresh blocked pi's main thread for over a second. */
|
|
63
80
|
derived = new Map();
|
|
@@ -65,11 +82,20 @@ export class UiData {
|
|
|
65
82
|
constructor(home) { this.home = home; }
|
|
66
83
|
refresh() {
|
|
67
84
|
const workflows = allWorkflows(this.home), known = new Set(), deferred = new Map();
|
|
68
|
-
const facts = new LazyMap(key => deferred.get(key)?.())
|
|
85
|
+
const facts = new LazyMap(key => deferred.get(key)?.());
|
|
86
|
+
const sessions = new LazyMap(key => deferred.has(key) ? deferred.get(key)() : this.transcript(key, sessions));
|
|
69
87
|
for (const w of workflows) {
|
|
70
|
-
|
|
88
|
+
let index; // only calls still followed need it
|
|
89
|
+
const journal = () => index ??= journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
|
|
71
90
|
for (const c of w.calls) {
|
|
72
91
|
known.add(c.callId);
|
|
92
|
+
const final = this.final.get(c.callId);
|
|
93
|
+
if (final && c.phase === "sealed" && c.endedAt === final.endedAt) {
|
|
94
|
+
facts.put(c.callId, final.facts);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (final)
|
|
98
|
+
this.final.delete(c.callId);
|
|
73
99
|
const load = () => {
|
|
74
100
|
deferred.delete(c.callId);
|
|
75
101
|
let tail = this.tails.get(c.callId);
|
|
@@ -85,16 +111,23 @@ export class UiData {
|
|
|
85
111
|
this.derived.set(c.callId, cached);
|
|
86
112
|
}
|
|
87
113
|
const value = { ...cached.facts, live: undefined };
|
|
88
|
-
const proposal =
|
|
114
|
+
const proposal = journal().calls.get(`${c.key}\0${c.gen}`);
|
|
89
115
|
value.task ||= String(proposal?.spec?.task ?? "");
|
|
90
|
-
const loss =
|
|
91
|
-
const launch =
|
|
116
|
+
const loss = journal().losses.get(String(c.exec)) ?? -1;
|
|
117
|
+
const launch = journal().execs.get(String(c.exec)) ?? -1;
|
|
92
118
|
if (c.phase !== "sealed" && loss > launch)
|
|
93
119
|
value.activity = "connection dropped, retrying";
|
|
94
120
|
if (c.phase !== "sealed")
|
|
95
121
|
value.live = readLive(join(callDir(this.home, w.wid, c.key, c.gen), LIVE_FILE));
|
|
96
122
|
sessions.put(c.callId, cached.entries);
|
|
97
123
|
facts.put(c.callId, value);
|
|
124
|
+
// A call sealed a while ago writes nothing more (a follow-up opens a new generation and session), so its
|
|
125
|
+
// session is not stat'ed again: polling every historical session twice a second kept pi's main thread busy.
|
|
126
|
+
if (c.phase === "sealed" && c.endedAt !== undefined && Date.now() - c.endedAt > FINAL_MS) {
|
|
127
|
+
this.final.set(c.callId, { endedAt: c.endedAt, path: callSession(this.home, w.wid, c.key, c.gen), facts: value });
|
|
128
|
+
this.tails.delete(c.callId);
|
|
129
|
+
this.derived.delete(c.callId);
|
|
130
|
+
}
|
|
98
131
|
};
|
|
99
132
|
// A finished call never read before (history at startup) is read when first shown or by the idle warm-up below;
|
|
100
133
|
// reading every historical session up front blocked pi's startup for seconds.
|
|
@@ -109,6 +142,9 @@ export class UiData {
|
|
|
109
142
|
this.tails.delete(key);
|
|
110
143
|
this.derived.delete(key);
|
|
111
144
|
}
|
|
145
|
+
for (const key of this.final.keys())
|
|
146
|
+
if (!known.has(key))
|
|
147
|
+
this.final.delete(key);
|
|
112
148
|
// Warm the history a slice at a time, so opening the list later finds it read without one long stall now.
|
|
113
149
|
const until = performance.now() + this.warmMs;
|
|
114
150
|
for (const load of [...deferred.values()]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-durable-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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",
|