pi-durable-subagents 1.0.7 → 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 CHANGED
@@ -1,5 +1,20 @@
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
+
3
18
  ## 1.0.7
4
19
 
5
20
  - Changes to `config.json` (`defaultModel`, `pools`, `providers`, `memory`,
@@ -1,13 +1,14 @@
1
- import { readdirSync, readFileSync } from "node:fs";
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 pruned = new Set(ledger.filter(e => e.type === "pruned").map(e => String(e.wid)));
21
- const ids = new Set([...ledger.filter(e => e.type === JT.created).map(e => String(e.wid)), ...names].filter(wid => !pruned.has(wid)));
22
- const createdBy = new Map();
23
- for (const e of ledger)
24
- if (e.type === JT.created && !createdBy.has(e.wid))
25
- createdBy.set(e.wid, e);
26
- return [...ids].sort().map(wid => {
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 = createdBy.get(wid) ?? entries.find(e => e.type === JT.created);
29
- return { wid, origin: created?.origin, entries };
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
- return entries.some(g => g.type === "generation" && !entries.some(s => s.type === JT.sealed && String(s.call).endsWith(`/${String(g.key)}@${String(g.gen)}`)));
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
- /** P15: Refresh a question against durable workflow and child receipts at request time. */
90
- export function resolved(home, item) {
91
- if (readJournalSnapshot(journalPath(home, item.wid)).some(e => e.type === JT.attentionResolved && e.id === item.id && e.rev === item.rev))
92
- return true;
93
- if (item.kind !== "question" || !item.session || !item.qid)
94
- return false;
95
- let text;
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
- text = readFileSync(item.session, "utf8");
133
+ stat = statSync(path);
98
134
  }
99
135
  catch (error) {
100
- if (error.code === "ENOENT")
101
- return false;
136
+ if (error.code === "ENOENT") {
137
+ asks.delete(path);
138
+ return undefined;
139
+ }
102
140
  throw error;
103
141
  }
104
- const lines = text.split("\n");
105
- for (let index = 0; index < lines.length; index++) {
106
- if (!lines[index])
107
- continue;
108
- let entry;
109
- try {
110
- entry = JSON.parse(lines[index]);
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
- catch (error) {
113
- if (index >= lines.length - 2)
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
- throw error;
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
- const msg = entry.type === "message" ? entry.message : undefined;
118
- if (msg?.role === "toolResult" && msg.toolName === "ask" && !msg.isError && msg.details?.qid === item.qid && msg.details?.rev === item.rev)
119
- return true;
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
- return false;
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
  }
@@ -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, stat } from "node:fs/promises";
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
- const s = await stat(path);
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 = await readConfig(path);
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.
@@ -14,7 +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, readConfig, recordConfig, watchConfig } from "./config.js";
17
+ import { configPath, configProblem, configStamp, recordConfig, stampedConfig, watchConfig } from "./config.js";
18
18
  /** P2, C4, K6: Acquire the single authority before opening ledgers, recover, and idle-exit. */
19
19
  export async function main(options = {}) {
20
20
  const home = options.home ?? dsaHome();
@@ -26,9 +26,10 @@ export async function main(options = {}) {
26
26
  try {
27
27
  let config = options.config, stamp;
28
28
  if (!config) {
29
- // The stamp is taken before the read: a change made while reading is applied by the first check.
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.
30
31
  stamp = await configStamp(configPath(home));
31
- const raw = await readConfig(configPath(home));
32
+ const raw = stampedConfig(stamp);
32
33
  const problem = configProblem(raw);
33
34
  if (problem)
34
35
  console.error(`durable-subagents: config.json: ${problem}`);
@@ -67,17 +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
- export function snapshotFromEntries(wid, entries) {
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
- const terminal = current.findLast(e => e.type === JT.done || (e.type === "resumed" && !e.call));
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
78
  const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
80
- const resolved = new Set(entries.filter(e => e.type === JT.attentionResolved).map(e => `${e.id}@${e.rev}`));
79
+ const resolved = new Set();
81
80
  const generations = new Set(), retired = new Set();
82
81
  const attention = [];
83
82
  // P31: usage per call id, deduplicated by message id; a seal carries the authoritative total.
@@ -85,191 +84,270 @@ export function snapshotFromEntries(wid, entries) {
85
84
  const tools = new Map();
86
85
  // P7, P27: forward / forward-delivered / forward-retired, by destination call; retirements carry only rid2.
87
86
  const sends = new Map(), byRid2 = new Map();
88
- for (const e of entries) {
89
- if (["call", "generation", "refused", "reused"].includes(e.type)) {
90
- if (boundary >= 0 && e.seq < entries[boundary].seq)
91
- continue;
92
- const key = String(e.key), gen = Number(e.gen) || (e.type === "refused" ? 0 : 1);
93
- const callId = e.type === "reused" ? String(e.from) : `${wid}@${rev}/${key}@${gen}`;
94
- if (e.type === "generation")
95
- generations.add(callId);
96
- const result = e.type === "refused" ? refusedResult(key, e.reason) :
97
- e.type === "reused" ? entries.find(s => s.type === JT.sealed && s.call === e.from)?.result : undefined;
98
- const wanted = e.spec?.model;
99
- calls.set(callId, { key, gen, callId, pos: Number(e.pos), agent: String(e.spec?.agent ?? ""),
100
- // Until a slot is acquired (`selected`), show the model the call asked for, not nothing.
101
- ...(typeof wanted === "string" && wanted ? { model: wanted } : {}),
102
- phase: result ? "sealed" : "queued", ...(result ? { result, endedAt: e.ts } : {}),
103
- ...(e.type === "refused" ? { refused: String(e.reason) } : {}), ...(e.type === "reused" ? { reused: String(e.from) } : {}) });
104
- }
105
- else if (e.type === "retired") {
106
- retired.add(String(e.call));
107
- }
108
- else if (e.type === JT.exec) {
109
- const call = calls.get(String(e.call));
110
- if (!call)
111
- continue;
112
- // An execution waits for a provider slot and memory before it launches; it is running once `selected` says so
113
- // (otherwise a call still waiting for a slot reads "thinking · 2m").
114
- call.exec = String(e.exec);
115
- call.phase = "queued";
116
- call.startedAt ??= e.ts;
117
- call.lastActivity = e.ts;
118
- byExec.set(call.exec, call);
119
- delete call.hibernated;
120
- hibernating.delete(call.callId);
121
- }
122
- else if (e.type === "hibernated") {
123
- // The decision to hibernate precedes the fence; the slot is released only once the execution is fenced.
124
- const call = calls.get(String(e.call));
125
- if (call?.exec && call.exec === e.exec)
126
- hibernating.set(call.callId, call.exec);
127
- }
128
- else if (e.type === "answer-bound" || (e.type === "resumed" && e.call)) {
129
- const call = calls.get(String(e.call));
130
- if (call) {
131
- delete call.hibernated;
132
- hibernating.delete(call.callId);
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) } : {}) });
133
109
  }
134
- }
135
- else if (e.type === JT.fenced) {
136
- // A fenced execution without a seal (stop-all, a quit pi, a loss before its continuation) waits to run again.
137
- const call = byExec.get(String(e.exec));
138
- if (call && call.phase === "running")
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);
139
120
  call.phase = "queued";
140
- if (call && hibernating.get(call.callId) === e.exec)
141
- call.hibernated = true;
142
- }
143
- else if (e.type === "selected") {
144
- const call = byExec.get(String(e.exec)), m = e.model;
145
- if (call && m)
146
- call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
147
- if (call && call.phase === "queued")
148
- call.phase = "running";
149
- }
150
- else if (e.type === "observation") {
151
- // Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
152
- const call = byExec.get(String(e.exec));
153
- if (call)
121
+ call.startedAt ??= e.ts;
154
122
  call.lastActivity = e.ts;
155
- if (call && e.type === "observation" && e.event?.type === "tool_execution_start")
156
- tools.set(call.callId, (tools.get(call.callId) ?? 0) + 1);
157
- }
158
- else if (e.type === "usage") {
159
- const id = `${e.call}:${e.id}`, u = e.usage;
160
- if (seen.has(id) || !u)
161
- continue;
162
- seen.add(id);
163
- const total = live.get(String(e.call)) ?? zero();
164
- total.input += u.input;
165
- total.output += u.output;
166
- total.costUsd += u.costUsd;
167
- live.set(String(e.call), total);
168
- }
169
- else if (e.type === JT.sealed) {
170
- const usage = e.result?.usage;
171
- if (usage)
172
- sealedUsage.set(String(e.call), usage);
173
- const call = calls.get(String(e.call));
174
- if (!call)
175
- continue;
176
- call.phase = "sealed";
177
- call.result = e.result;
178
- call.endedAt = e.ts;
179
- delete call.hibernated;
180
- hibernating.delete(call.callId);
181
- }
182
- else if (e.type === JT.attention) {
183
- const item = e.item;
184
- if (!resolved.has(`${item.id}@${item.rev}`))
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;
185
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
+ }
186
210
  }
187
- else if (e.type === "forward") {
188
- const envelope = e.envelope;
189
- const send = { rid: String(e.rid), kind: String(envelope?.kind ?? ""), state: "pending", at: e.ts,
190
- ...(envelope?.kind === "model" && envelope.body?.provider ? { model: `${envelope.body.provider}/${envelope.body.model}` } : {}) };
191
- const list = sends.get(String(e.dest)) ?? [];
192
- list.push(send);
193
- sends.set(String(e.dest), list);
194
- byRid2.set(`${e.dest}\n${e.rid2}`, send);
195
- byRid2.set(String(e.rid2), send);
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
196
222
  }
197
- else if (e.type === "forward-delivered" || e.type === "forward-retired") {
198
- const send = byRid2.get(e.type === "forward-delivered" ? `${e.call}\n${e.rid2}` : String(e.rid2));
199
- if (!send || send.state !== "pending")
200
- continue;
201
- send.state = e.type === "forward-delivered" ? "delivered" : "retired";
202
- send.at = e.ts;
203
- if (e.type === "forward-delivered" && e.reason !== undefined)
204
- send.reason = String(e.reason);
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
+ }
205
246
  }
206
- }
207
- for (const item of attention) {
208
- const call = item.kind === "question" ? [...calls.values()].find(c => item.id.startsWith(`q:${c.callId}:`)) : undefined;
209
- if (call && (call.phase === "running" || call.phase === "queued"))
210
- call.phase = "asking"; // a hibernated asker is fenced
211
- }
212
- for (const c of calls.values())
213
- if (c.hibernated && c.phase !== "asking")
214
- delete c.hibernated;
215
- const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
216
- const list = [...calls.values()];
217
- const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
218
- for (const c of list) {
219
- counts[c.phase]++;
220
- const usage = usageOf(c.callId);
221
- if (usage)
222
- c.usage = { ...usage };
223
- const n = tools.get(c.callId);
224
- if (n)
225
- c.tools = n;
226
- const forwarded = sends.get(c.callId);
227
- if (forwarded) {
228
- c.sends = forwarded;
229
- const pending = forwarded.filter(pendingMessage).length;
230
- if (pending)
231
- c.pending = pending;
232
- const switching = c.phase !== "sealed" ? forwarded.findLast(s => s.kind === "model" && s.state === "pending")?.model : undefined;
233
- if (switching && switching !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
234
- 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;
235
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
+ };
236
267
  }
237
- const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
238
- for (const c of after)
239
- c.afterEnd = true;
240
- const followUps = after.length;
241
- const usage = zero();
242
- for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
243
- const u = usageOf(id);
244
- usage.input += u.input;
245
- usage.output += u.output;
246
- usage.costUsd += u.costUsd;
247
- }
248
- return {
249
- wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
250
- ...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
251
- status: done ? done.status : "running",
252
- ...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: done.result } : {}),
253
- ...(done ? { endedAt: done.ts } : {}),
254
- ...(followUps ? { followUps } : {}),
255
- calls: list, counts, attention, usage,
256
- };
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();
257
273
  }
258
274
  /** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
259
275
  // A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
260
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();
261
279
  const derived = new WeakMap();
262
280
  export function workflowSnapshot(home, wid) {
263
- const entries = readJournalSnapshot(journalPath(home, wid)), hit = derived.get(entries);
264
- const wf = hit?.wid === wid ? hit.snapshot : snapshotFromEntries(wid, entries);
265
- if (hit?.wid !== wid && entries.length)
266
- derived.set(entries, { wid, snapshot: wf });
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
+ }
267
315
  const planned = plannedTotal(home, wid, wf.rev);
268
316
  return planned === undefined ? wf : { ...wf, planned };
269
317
  }
270
318
  /** Ops: wids archived by `prune` (orchestrator ledger `pruned{wid}`); they no longer exist for any reader. */
271
319
  function prunedIds(home) {
272
- return new Set(readJournalSnapshot(orchLedger(home)).filter(e => e.type === "pruned").map(e => String(e.wid)));
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;
273
351
  }
274
352
  function workflowIds(home) {
275
353
  let wids = [];
@@ -286,19 +364,21 @@ function workflowIds(home) {
286
364
  /** Drain: the one hold rule. drain/undrain entries apply to every workflow (no scope), one session's (`origin`) or one
287
365
  * workflow (`wid`). A workflow is held when the newest entry that applies to it is a drain recorded after it was created. */
288
366
  export function holdOf(ledger, wid, origin) {
289
- const applies = (e) => (e.wid === undefined && e.origin === undefined) || e.wid === wid || (origin !== undefined && e.origin === origin);
290
- const last = ledger.findLast(e => (e.type === "drain" || e.type === "undrain") && applies(e));
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;
291
371
  if (last?.type !== "drain")
292
372
  return undefined;
293
- const created = ledger.find(e => e.type === JT.created && e.wid === wid);
373
+ const created = ix.created.get(wid);
294
374
  return !created || created.seq < last.seq ? last : undefined;
295
375
  }
296
376
  /** Drain: the workflows a drain (stop-all, a quit pi) holds until resume, and since when. */
297
377
  export function heldWorkflows(home) {
298
- const ledger = readJournalSnapshot(orchLedger(home));
299
- const origin = (wid) => ledger.find(e => e.type === JT.created && e.wid === wid)?.origin;
378
+ const ledger = readJournalSnapshot(orchLedger(home)), ix = ledgerIndex(ledger);
379
+ const origin = (wid) => ix.created.get(wid)?.origin;
300
380
  const hold = (wid) => holdOf(ledger, wid, origin(wid));
301
- const since = ledger.filter(e => e.type === "drain").at(-1)?.ts;
381
+ const since = ix.lastDrain !== undefined ? ledger[ix.lastDrain].ts : undefined;
302
382
  return { ...(since !== undefined ? { since } : {}), held: wid => hold(wid) !== undefined };
303
383
  }
304
384
  /** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
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 = 15;
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)?.()), sessions = 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
- const index = journalIndex(readJournalSnapshot(journalPath(this.home, w.wid)));
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 = index.calls.get(`${c.key}\0${c.gen}`);
114
+ const proposal = journal().calls.get(`${c.key}\0${c.gen}`);
89
115
  value.task ||= String(proposal?.spec?.task ?? "");
90
- const loss = index.losses.get(String(c.exec)) ?? -1;
91
- const launch = index.execs.get(String(c.exec)) ?? -1;
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.7",
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",