pi-durable-subagents 1.0.7 → 1.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +8 -2
- package/dist/agent/main/snapshots.js +117 -42
- package/dist/agent/main/tool.js +10 -1
- package/dist/agent/main.js +5 -4
- package/dist/cli/main.js +1 -1
- package/dist/orchestrator/config.js +8 -4
- package/dist/orchestrator/engine.js +23 -2
- package/dist/orchestrator/executor/index.js +126 -36
- package/dist/orchestrator/executor/session.js +8 -0
- package/dist/orchestrator/main.js +4 -3
- package/dist/orchestrator/snapshot.js +273 -177
- package/dist/ui/data.js +42 -6
- package/dist/ui/screen.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
78
|
const hibernating = new Map(); // callId → exec that decided to hibernate and is not fenced yet
|
|
80
|
-
const resolved = new Set(
|
|
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,285 @@ 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 === "model-used") {
|
|
156
|
+
// Evidence of a switch: the execution answered with another model than it launched with.
|
|
157
|
+
const call = byExec.get(String(e.exec)), m = e.model;
|
|
158
|
+
if (call && m)
|
|
159
|
+
call.model = m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
160
|
+
}
|
|
161
|
+
else if (e.type === "observation") {
|
|
162
|
+
// Only what the agent did counts as activity; tracker scans and time checkpoints are bookkeeping.
|
|
163
|
+
const call = byExec.get(String(e.exec));
|
|
164
|
+
if (call)
|
|
165
|
+
call.lastActivity = e.ts;
|
|
166
|
+
if (call && e.type === "observation" && e.event?.type === "tool_execution_start")
|
|
167
|
+
tools.set(call.callId, (tools.get(call.callId) ?? 0) + 1);
|
|
168
|
+
}
|
|
169
|
+
else if (e.type === "usage") {
|
|
170
|
+
const id = `${e.call}:${e.id}`, u = e.usage;
|
|
171
|
+
if (seen.has(id) || !u)
|
|
172
|
+
continue;
|
|
173
|
+
seen.add(id);
|
|
174
|
+
const total = live.get(String(e.call)) ?? zero();
|
|
175
|
+
total.input += u.input;
|
|
176
|
+
total.output += u.output;
|
|
177
|
+
total.costUsd += u.costUsd;
|
|
178
|
+
live.set(String(e.call), total);
|
|
179
|
+
}
|
|
180
|
+
else if (e.type === JT.sealed) {
|
|
181
|
+
const usage = e.result?.usage;
|
|
182
|
+
if (usage)
|
|
183
|
+
sealedUsage.set(String(e.call), usage);
|
|
184
|
+
const call = calls.get(String(e.call));
|
|
185
|
+
if (!call)
|
|
186
|
+
continue;
|
|
187
|
+
call.phase = "sealed";
|
|
188
|
+
call.result = e.result;
|
|
189
|
+
call.endedAt = e.ts;
|
|
190
|
+
delete call.hibernated;
|
|
191
|
+
hibernating.delete(call.callId);
|
|
192
|
+
}
|
|
193
|
+
else if (e.type === JT.attention) {
|
|
194
|
+
const item = e.item;
|
|
185
195
|
attention.push(item);
|
|
196
|
+
}
|
|
197
|
+
else if (e.type === "forward") {
|
|
198
|
+
const envelope = e.envelope;
|
|
199
|
+
const send = { rid: String(e.rid), kind: String(envelope?.kind ?? ""), state: "pending", at: e.ts,
|
|
200
|
+
...(envelope?.kind === "model" && envelope.body?.provider ? { model: `${envelope.body.provider}/${envelope.body.model}` } : {}) };
|
|
201
|
+
const list = sends.get(String(e.dest)) ?? [];
|
|
202
|
+
list.push(send);
|
|
203
|
+
sends.set(String(e.dest), list);
|
|
204
|
+
// A withdrawn model request no longer stands (the executor ignores it for the next launch as well).
|
|
205
|
+
if (envelope?.kind === "withdraw")
|
|
206
|
+
for (const rid2 of envelope.body?.rids ?? []) {
|
|
207
|
+
const target = byRid2.get(`${e.dest}\n${rid2}`);
|
|
208
|
+
if (target?.kind === "model" && target.state === "pending")
|
|
209
|
+
target.reason = "withdrawn";
|
|
210
|
+
}
|
|
211
|
+
byRid2.set(`${e.dest}\n${e.rid2}`, send);
|
|
212
|
+
byRid2.set(String(e.rid2), send);
|
|
213
|
+
}
|
|
214
|
+
else if (e.type === "forward-delivered" || e.type === "forward-retired") {
|
|
215
|
+
const send = byRid2.get(e.type === "forward-delivered" ? `${e.call}\n${e.rid2}` : String(e.rid2));
|
|
216
|
+
if (!send || send.state !== "pending")
|
|
217
|
+
continue;
|
|
218
|
+
send.state = e.type === "forward-delivered" ? "delivered" : "retired";
|
|
219
|
+
send.at = e.ts;
|
|
220
|
+
if (e.type === "forward-delivered" && e.reason !== undefined)
|
|
221
|
+
send.reason = String(e.reason);
|
|
222
|
+
}
|
|
186
223
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
224
|
+
}
|
|
225
|
+
apply(entries);
|
|
226
|
+
function finish() {
|
|
227
|
+
const done = terminal?.type === JT.done ? terminal : undefined;
|
|
228
|
+
// Finish owns every exposed object; UI decoration and consumers cannot alter reducer history.
|
|
229
|
+
const list = structuredClone([...calls.values()]);
|
|
230
|
+
const openAttention = structuredClone(attention.filter(item => !resolved.has(`${item.id}@${item.rev}`)));
|
|
231
|
+
for (const item of openAttention) {
|
|
232
|
+
const call = item.kind === "question" ? list.find(c => item.id.startsWith(`q:${c.callId}:`)) : undefined;
|
|
233
|
+
if (call && (call.phase === "running" || call.phase === "queued"))
|
|
234
|
+
call.phase = "asking"; // a hibernated asker is fenced
|
|
196
235
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
236
|
+
for (const c of list)
|
|
237
|
+
if (c.hibernated && c.phase !== "asking")
|
|
238
|
+
delete c.hibernated;
|
|
239
|
+
const usageOf = (callId) => sealedUsage.get(callId) ?? live.get(callId);
|
|
240
|
+
const counts = { queued: 0, running: 0, asking: 0, sealed: 0 };
|
|
241
|
+
for (const c of list) {
|
|
242
|
+
counts[c.phase]++;
|
|
243
|
+
const usage = usageOf(c.callId);
|
|
244
|
+
if (usage)
|
|
245
|
+
c.usage = { ...usage };
|
|
246
|
+
const n = tools.get(c.callId);
|
|
247
|
+
if (n)
|
|
248
|
+
c.tools = n;
|
|
249
|
+
const forwarded = sends.get(c.callId);
|
|
250
|
+
if (forwarded) {
|
|
251
|
+
c.sends = structuredClone(forwarded);
|
|
252
|
+
const pending = forwarded.filter(pendingMessage).length;
|
|
253
|
+
if (pending)
|
|
254
|
+
c.pending = pending;
|
|
255
|
+
const last = c.phase !== "sealed" && !retired.has(c.callId) ? forwarded.findLast(s => s.kind === "model" && s.reason !== "withdrawn") : undefined;
|
|
256
|
+
if (last?.reason !== undefined)
|
|
257
|
+
c.switchFailed = `${last.model} (${last.reason})`;
|
|
258
|
+
else if (last && last.state !== "retired" && last.model !== c.model?.replace(/:(off|minimal|low|medium|high|xhigh|max)$/, ""))
|
|
259
|
+
c.switching = last.model;
|
|
260
|
+
}
|
|
205
261
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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;
|
|
262
|
+
const after = done ? list.filter(c => generations.has(c.callId) && c.phase !== "sealed" && !retired.has(c.callId)) : [];
|
|
263
|
+
for (const c of after)
|
|
264
|
+
c.afterEnd = true;
|
|
265
|
+
const followUps = after.length;
|
|
266
|
+
const usage = zero();
|
|
267
|
+
for (const id of new Set([...live.keys(), ...sealedUsage.keys()])) {
|
|
268
|
+
const u = usageOf(id);
|
|
269
|
+
usage.input += u.input;
|
|
270
|
+
usage.output += u.output;
|
|
271
|
+
usage.costUsd += u.costUsd;
|
|
235
272
|
}
|
|
273
|
+
return {
|
|
274
|
+
wid, rev, ...(typeof created?.name === "string" ? { name: created.name } : {}),
|
|
275
|
+
...(created ? { origin: String(created.origin), cwd: String(created.cwd), startedAt: created.ts } : {}),
|
|
276
|
+
status: done ? done.status : "running",
|
|
277
|
+
...(done?.error ? { error: String(done.error) } : {}), ...(done && "result" in done ? { result: structuredClone(done.result) } : {}),
|
|
278
|
+
...(done ? { endedAt: done.ts } : {}),
|
|
279
|
+
...(followUps ? { followUps } : {}),
|
|
280
|
+
calls: list, counts, attention: openAttention, usage,
|
|
281
|
+
};
|
|
236
282
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
};
|
|
283
|
+
// Only the current revision's reuse looks ahead for a seal (entries before the boundary are skipped).
|
|
284
|
+
return { apply, finish, hasReuse: current.some(e => e.type === "reused") };
|
|
285
|
+
}
|
|
286
|
+
export function snapshotFromEntries(wid, entries) {
|
|
287
|
+
return snapshotReducer(wid, entries).finish();
|
|
257
288
|
}
|
|
258
289
|
/** P25: Snapshot one workflow from its durable journal (v12 §4: plus the planned total of its pinned run body). */
|
|
259
290
|
// A journal snapshot is immutable and replaced on every append, so its derived workflow snapshot is reused until then:
|
|
260
291
|
// re-deriving every historical workflow on each UI refresh dominated pi's main thread.
|
|
292
|
+
const FOLDS = 256; // workflows whose fold state is kept; pruned and long-idle ones fall out
|
|
293
|
+
const folds = new Map();
|
|
261
294
|
const derived = new WeakMap();
|
|
262
295
|
export function workflowSnapshot(home, wid) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
if (hit?.wid
|
|
266
|
-
|
|
296
|
+
const path = journalPath(home, wid), entries = readJournalSnapshot(path), hit = derived.get(entries);
|
|
297
|
+
let wf;
|
|
298
|
+
if (hit?.wid === wid)
|
|
299
|
+
wf = hit.snapshot;
|
|
300
|
+
else {
|
|
301
|
+
const prior = folds.get(path);
|
|
302
|
+
// The journal reader preserves entry identity only when extending the same committed prefix.
|
|
303
|
+
const prefix = prior?.wid === wid && entries.length >= prior.length &&
|
|
304
|
+
(prior.length === 0 || entries[prior.length - 1] === prior.last);
|
|
305
|
+
let reusable = prefix;
|
|
306
|
+
if (reusable)
|
|
307
|
+
for (let i = prior.length; i < entries.length; i++) {
|
|
308
|
+
const e = entries[i];
|
|
309
|
+
// Revisions change earlier call identities/boundaries. Reuse looks ahead for the first seal.
|
|
310
|
+
if (e.type === "revised" || e.type === "wf-created" || e.type === "reused" ||
|
|
311
|
+
(prior.reducer.hasReuse && e.type === JT.sealed)) {
|
|
312
|
+
reusable = false;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const reducer = reusable ? prior.reducer : snapshotReducer(wid, entries);
|
|
317
|
+
if (reusable)
|
|
318
|
+
reducer.apply(entries, prior.length);
|
|
319
|
+
wf = reducer.finish();
|
|
320
|
+
if (entries.length) {
|
|
321
|
+
folds.delete(path);
|
|
322
|
+
folds.set(path, { wid, length: entries.length, last: entries.at(-1), reducer }); // least recently folded first
|
|
323
|
+
if (folds.size > FOLDS)
|
|
324
|
+
folds.delete(folds.keys().next().value);
|
|
325
|
+
derived.set(entries, { wid, snapshot: wf });
|
|
326
|
+
}
|
|
327
|
+
else
|
|
328
|
+
folds.delete(path);
|
|
329
|
+
}
|
|
267
330
|
const planned = plannedTotal(home, wid, wf.rev);
|
|
268
331
|
return planned === undefined ? wf : { ...wf, planned };
|
|
269
332
|
}
|
|
270
333
|
/** Ops: wids archived by `prune` (orchestrator ledger `pruned{wid}`); they no longer exist for any reader. */
|
|
271
334
|
function prunedIds(home) {
|
|
272
|
-
return
|
|
335
|
+
return ledgerIndex(readJournalSnapshot(orchLedger(home))).pruned;
|
|
336
|
+
}
|
|
337
|
+
const ledgerIndexes = new WeakMap();
|
|
338
|
+
export function ledgerIndex(ledger) {
|
|
339
|
+
let ix = ledgerIndexes.get(ledger);
|
|
340
|
+
if (!ix || ix.length > ledger.length || (ix.length && ledger[ix.length - 1] !== ix.last)) {
|
|
341
|
+
ix = { length: 0, created: new Map(), pruned: new Set(), byWid: new Map(), byOrigin: new Map() };
|
|
342
|
+
ledgerIndexes.set(ledger, ix);
|
|
343
|
+
}
|
|
344
|
+
for (let i = ix.length; i < ledger.length; i++) {
|
|
345
|
+
const e = ledger[i];
|
|
346
|
+
if (e.type === JT.created) {
|
|
347
|
+
if (!ix.created.has(e.wid))
|
|
348
|
+
ix.created.set(e.wid, e);
|
|
349
|
+
}
|
|
350
|
+
else if (e.type === "pruned")
|
|
351
|
+
ix.pruned.add(String(e.wid));
|
|
352
|
+
else if (e.type === "drain" || e.type === "undrain") {
|
|
353
|
+
if (e.type === "drain")
|
|
354
|
+
ix.lastDrain = i;
|
|
355
|
+
if (e.wid === undefined && e.origin === undefined)
|
|
356
|
+
ix.global = i;
|
|
357
|
+
if (e.wid !== undefined)
|
|
358
|
+
ix.byWid.set(e.wid, i);
|
|
359
|
+
if (e.origin !== undefined)
|
|
360
|
+
ix.byOrigin.set(e.origin, i);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
ix.length = ledger.length;
|
|
364
|
+
ix.last = ledger[ledger.length - 1];
|
|
365
|
+
return ix;
|
|
273
366
|
}
|
|
274
367
|
function workflowIds(home) {
|
|
275
368
|
let wids = [];
|
|
@@ -286,19 +379,21 @@ function workflowIds(home) {
|
|
|
286
379
|
/** Drain: the one hold rule. drain/undrain entries apply to every workflow (no scope), one session's (`origin`) or one
|
|
287
380
|
* workflow (`wid`). A workflow is held when the newest entry that applies to it is a drain recorded after it was created. */
|
|
288
381
|
export function holdOf(ledger, wid, origin) {
|
|
289
|
-
|
|
290
|
-
const
|
|
382
|
+
// The newest applicable entry is the newest of the newest unscoped, the newest for this wid and the newest for its origin.
|
|
383
|
+
const ix = ledgerIndex(ledger);
|
|
384
|
+
const at = Math.max(ix.global ?? -1, ix.byWid.get(wid) ?? -1, origin !== undefined ? ix.byOrigin.get(origin) ?? -1 : -1);
|
|
385
|
+
const last = at >= 0 ? ledger[at] : undefined;
|
|
291
386
|
if (last?.type !== "drain")
|
|
292
387
|
return undefined;
|
|
293
|
-
const created =
|
|
388
|
+
const created = ix.created.get(wid);
|
|
294
389
|
return !created || created.seq < last.seq ? last : undefined;
|
|
295
390
|
}
|
|
296
391
|
/** Drain: the workflows a drain (stop-all, a quit pi) holds until resume, and since when. */
|
|
297
392
|
export function heldWorkflows(home) {
|
|
298
|
-
const ledger = readJournalSnapshot(orchLedger(home));
|
|
299
|
-
const origin = (wid) =>
|
|
393
|
+
const ledger = readJournalSnapshot(orchLedger(home)), ix = ledgerIndex(ledger);
|
|
394
|
+
const origin = (wid) => ix.created.get(wid)?.origin;
|
|
300
395
|
const hold = (wid) => holdOf(ledger, wid, origin(wid));
|
|
301
|
-
const since =
|
|
396
|
+
const since = ix.lastDrain !== undefined ? ledger[ix.lastDrain].ts : undefined;
|
|
302
397
|
return { ...(since !== undefined ? { since } : {}), held: wid => hold(wid) !== undefined };
|
|
303
398
|
}
|
|
304
399
|
/** P25: Snapshot every workflow under DSA_HOME (newest first by wid, which is a ULID); `paused` marks work a drain holds. */
|
|
@@ -324,7 +419,7 @@ export function compactWorkflow(wf) {
|
|
|
324
419
|
calls: wf.calls.map(c => {
|
|
325
420
|
const r = c.result, last = r?.output?.split("\n").map(l => l.trim()).filter(Boolean).at(-1);
|
|
326
421
|
return { key: c.key, gen: c.gen, callId: c.callId, phase: c.phase, ...(r ? { status: r.status, ok: r.ok } : {}),
|
|
327
|
-
...(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 } : {}),
|
|
422
|
+
...(c.model ? { model: c.model } : {}), ...(c.tools ? { tools: c.tools } : {}), ...(c.pending ? { pending: c.pending } : {}), ...(c.switching ? { switching: c.switching } : {}), ...(c.switchFailed ? { switchFailed: c.switchFailed } : {}), ...(nonzero(c.usage) ? { usage: c.usage } : {}),
|
|
328
423
|
...(last ? { lastLine: clip(last, 200) } : {}), ...(r?.error ? { error: clip(r.error, 300) } : {}), ...(c.hibernated ? { hibernated: true } : {}) };
|
|
329
424
|
}),
|
|
330
425
|
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 } : {}) })),
|
|
@@ -418,7 +513,8 @@ export function statusBrief(home, options = {}) {
|
|
|
418
513
|
...(live && c.phase !== "asking" && quiet !== undefined && quiet >= 60_000 ? { quiet: age(quiet) } : {}),
|
|
419
514
|
...(live && c.startedAt !== undefined ? { tokens: tokens(c.usage) } : {}),
|
|
420
515
|
...(c.result ? { status: c.result.status, ...(c.result.error ? { error: clip(c.result.error, 200) } : {}) } : {}),
|
|
421
|
-
...(c.hibernated ? { hibernated: true } : {})
|
|
516
|
+
...(c.hibernated ? { hibernated: true } : {}),
|
|
517
|
+
...(live && c.switching ? { switching: c.switching } : {}), ...(live && c.switchFailed ? { switchFailed: c.switchFailed } : {}) };
|
|
422
518
|
});
|
|
423
519
|
const asking = open.filter(a => a.kind === "question" && a.call).map(a => ({ to: `${w.wid}/${callKey(a.call)}`, ...(a.qid ? { qid: a.qid } : {}),
|
|
424
520
|
...(w.calls.some(c => c.callId === a.call && c.hibernated) ? { hibernated: true } : {}), question: clip(a.text, 300) }));
|
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/dist/ui/screen.js
CHANGED
|
@@ -565,7 +565,7 @@ export class SubagentScreen {
|
|
|
565
565
|
const facts = this.data.facts.get(c.callId), active = w.calls.filter(c => c.phase !== "sealed"), done = w.calls.length - active.length;
|
|
566
566
|
const tabs = size.width < 60 ? `${c.key} ${w.calls.indexOf(c) + 1}/${w.calls.length}` : `${[...active.map(c => c.key), ...(done ? [`${done} done`] : [])].join(" · ")} ← → switch`;
|
|
567
567
|
const tools = toolCount(facts?.tools), pending = pendingText(c.pending), rule = this.theme.fg("borderMuted", "─".repeat(size.width));
|
|
568
|
-
const switching = c.switching ? ` → ${this.name(c.switching)} (
|
|
568
|
+
const switching = c.switching ? ` → ${this.name(c.switching)} (requested)` : "";
|
|
569
569
|
const head = [tabs, `${label(c)} · ${this.name(facts?.model ?? c.model)} ▾${switching} · ${facts?.thinking ?? "off"} ▾${tools ? ` · ${tools}` : ""}${pending ? ` · ${pending}` : ""}`,
|
|
570
570
|
this.theme.fg("dim", this.spend(c, facts)), rule];
|
|
571
571
|
const asking = w.attention.some(a => a.kind === "question" && a.call === c.callId);
|