@aeondave/pi-persona 1.17.1 → 1.17.4
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/AGENTS.md +103 -64
- package/CHANGELOG.md +310 -1
- package/README.md +37 -23
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +280 -92
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +52 -17
- package/docs/MONITORS.md +62 -3
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +139 -38
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +15 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +26 -0
- package/src/bridge.ts +28 -10
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +436 -353
- package/src/core/capabilities.ts +1 -1
- package/src/core/config.ts +15 -10
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +1 -1
- package/src/core/timer.ts +34 -9
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +95 -27
- package/src/engine/async.ts +213 -155
- package/src/engine/child.ts +12 -4
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +99 -53
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +10 -20
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +47 -18
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/engine.ts +54 -28
- package/src/extension/hooks.ts +952 -868
- package/src/extension/identity.ts +41 -75
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +32 -7
- package/src/extension.ts +2965 -2611
- package/src/loader.ts +11 -3
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +33 -6
- package/src/orchestration/flow-run.ts +160 -32
- package/src/orchestration/flow.ts +20 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -11
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- package/src/persona/persona.ts +8 -4
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +108 -66
- package/src/tools/delegate-tool.ts +349 -283
- package/src/tools/delegate.ts +35 -216
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +35 -22
- package/src/tools/flow.ts +103 -30
- package/src/tools/intercom-tool.ts +202 -128
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +34 -9
- package/src/tools/monitor.ts +58 -6
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +34 -15
- package/src/ui/agent-overlay.ts +65 -16
- package/src/ui/agent-tree.ts +127 -19
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/persona-settings.ts +9 -3
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +366 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
package/src/engine/async.ts
CHANGED
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
*
|
|
7
7
|
* NOTE: live *steering* of a running child-process agent (resume/redirect) needs
|
|
8
8
|
* the cross-process broker (v0.5) or the InProcessEngine (v0.4). This module
|
|
9
|
-
* covers launch + peek + completion-notify
|
|
9
|
+
* covers launch + peek + completion-notify.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { ProgressSnapshot } from "./stream.ts";
|
|
12
|
+
import type { ProgressSnapshot, ToolEvent } from "./stream.ts";
|
|
13
13
|
import type { AgentResult } from "../orchestration/types.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
|
|
15
|
+
import { DEFAULT_ASYNC_RETAIN } from "../core/config.ts";
|
|
16
|
+
import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel, sanitizeStatusLabel } from "../core/display-label.ts";
|
|
15
17
|
import { compactTokens } from "../core/format.ts";
|
|
18
|
+
import { isThinkingLevel } from "../core/types.ts";
|
|
16
19
|
import { formatDuration } from "../core/time.ts";
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* deliberately patient: a long scan, a big generation, or a blocking command shows no visible
|
|
22
|
-
* progress yet is perfectly healthy, so both surfaces flag a leg only after a genuinely long quiet
|
|
21
|
+
/** "possibly stuck" — the soft stall signal shared by the operator card, the peek digest and the UI
|
|
22
|
+
* badge. It is deliberately patient: a long scan, a big generation, or a blocking command shows no
|
|
23
|
+
* visible progress yet is perfectly healthy, so every surface flags a leg only after a genuinely long quiet
|
|
23
24
|
* spell. Purely advisory (no auto-abort); the idle watchdog + token budget are the enforcing backstops. */
|
|
24
25
|
export const STALL_FLAG_MS = 90_000;
|
|
25
26
|
|
|
@@ -32,6 +33,7 @@ export interface AsyncRun {
|
|
|
32
33
|
status: "running" | "done" | "failed" | "stopped";
|
|
33
34
|
progress: ProgressSnapshot;
|
|
34
35
|
result?: AgentResult;
|
|
36
|
+
resultReceipt?: ResultReceipt;
|
|
35
37
|
error?: string;
|
|
36
38
|
/** The codename the launcher gave this run (e.g. "atlas-static") — the SAME name shown on the
|
|
37
39
|
* agent-tree node for this run. When set, it's the canonical display name (over the bare
|
|
@@ -40,15 +42,16 @@ export interface AsyncRun {
|
|
|
40
42
|
/** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
|
|
41
43
|
* so this module needs no import from delegate.ts just to render it. */
|
|
42
44
|
model?: string;
|
|
43
|
-
/** Clock (ms) this run
|
|
44
|
-
* "that leg took 2m 5s" reading is computed from. Both
|
|
45
|
+
/** Clock (ms) this run started executing (launch, or admission for a queued one), and the clock it
|
|
46
|
+
* settled at — the pair the supervisor's "that leg took 2m 5s" reading is computed from. Both
|
|
47
|
+
* come from the tracker's INJECTED clock,
|
|
45
48
|
* so a test can settle a two-hour run without waiting. Optional because runs also reach the
|
|
46
49
|
* renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
|
|
47
50
|
startedAt?: number;
|
|
48
51
|
settledAt?: number;
|
|
49
|
-
/** Clock (ms) of the last time
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
+
/** Clock (ms) of the last time the run made {@link madeHeadway} — NOT merely the last event. A worker
|
|
53
|
+
* looping without headway, or a tool streaming output forever, keeps emitting but never bumps this,
|
|
54
|
+
* so `now - lastAdvanceAt` is what tells "working" from "stuck". */
|
|
52
55
|
lastAdvanceAt?: number;
|
|
53
56
|
/** Waiting for semaphore admission; queued time is not agent execution time. */
|
|
54
57
|
queued?: boolean;
|
|
@@ -65,6 +68,36 @@ export interface AsyncRun {
|
|
|
65
68
|
* run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
|
|
66
69
|
* performed after launch() returns would re-insert an already-dead run and leak it. */
|
|
67
70
|
mutates?: boolean;
|
|
71
|
+
/** The id of the one `tasks` call this leg was launched by. Its members are delivered together, once the
|
|
72
|
+
* last of them settles (see {@link AsyncRunTracker.batchSettled}); a single-leg launch has none. */
|
|
73
|
+
batch?: string;
|
|
74
|
+
/** What a running run is blocked on, when it is the OPERATOR (a flow checkpoint modal): shown in the
|
|
75
|
+
* digest instead of a stall verdict, because silence while a human decides is not a wedge. */
|
|
76
|
+
awaiting?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What {@link madeHeadway} reads of one progress reading: a tracker snapshot, or a leg's `AgentProgress`
|
|
80
|
+
* (which carries no turns). */
|
|
81
|
+
export interface ProgressReading {
|
|
82
|
+
output?: string | undefined;
|
|
83
|
+
tokens?: number | undefined;
|
|
84
|
+
turns?: number | undefined;
|
|
85
|
+
toolEvent?: ToolEvent | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The ONE rule for "this run advanced": the assistant produced more (output grew, tokens or turns rose) or a
|
|
90
|
+
* tool call just COMPLETED. Raw tool output is deliberately not headway - a straggler that streams text
|
|
91
|
+
* forever (`npm run dev`, `--watch`, `tail -f`) must still read as stalled, so the operator-only card can
|
|
92
|
+
* flag it. Nothing is killed and the model is not woken; a legitimately noisy tool earns an advisory only.
|
|
93
|
+
* A completion counts once: the transient `toolEvent` rides the one snapshot it was emitted on, so
|
|
94
|
+
* replaying that snapshot credits nothing. Pure.
|
|
95
|
+
*/
|
|
96
|
+
export function madeHeadway(prev: ProgressReading | undefined, next: ProgressReading): boolean {
|
|
97
|
+
return (next.output?.length ?? 0) > (prev?.output?.length ?? 0)
|
|
98
|
+
|| (next.tokens ?? 0) > (prev?.tokens ?? 0)
|
|
99
|
+
|| (next.turns ?? 0) > (prev?.turns ?? 0)
|
|
100
|
+
|| (next.toolEvent?.phase === "end" && next.toolEvent !== prev?.toolEvent);
|
|
68
101
|
}
|
|
69
102
|
|
|
70
103
|
/** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
|
|
@@ -83,19 +116,31 @@ export class AsyncRunTracker {
|
|
|
83
116
|
/** Ids whose payload was evicted while STILL UNCOLLECTED, oldest first (insertion-ordered Set). */
|
|
84
117
|
private readonly droppedIds = new Set<string>();
|
|
85
118
|
private readonly completeListeners: Array<(run: AsyncRun) => void> = [];
|
|
119
|
+
private readonly createListeners: Array<(run: AsyncRun) => void> = [];
|
|
86
120
|
private seq = 0;
|
|
87
121
|
private readonly maxRetained: number;
|
|
88
122
|
private readonly now: () => number;
|
|
89
123
|
|
|
90
124
|
/** `now` is injected so the stall clock is deterministic under test (real time by default).
|
|
91
|
-
* `maxRetained` (default
|
|
92
|
-
* knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
125
|
+
* `maxRetained` (default {@link DEFAULT_ASYNC_RETAIN}; the extension passes `config.asyncRetain`) is the config-driven
|
|
126
|
+
* PI_PERSONA_ASYNC_RETAIN knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
93
127
|
constructor(opts?: { now?: () => number; maxRetained?: number }) {
|
|
94
128
|
this.now = opts?.now ?? ((): number => Date.now());
|
|
95
|
-
this.maxRetained = opts?.maxRetained ??
|
|
129
|
+
this.maxRetained = opts?.maxRetained ?? DEFAULT_ASYNC_RETAIN;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How many run ids this lineage has issued: what a restored instance must continue from. */
|
|
133
|
+
get issued(): number {
|
|
134
|
+
return this.seq;
|
|
96
135
|
}
|
|
97
136
|
|
|
98
|
-
|
|
137
|
+
/** Continue the id counter from a previous instance of the same session so ids never repeat within
|
|
138
|
+
* one history. Monotonic: anything that would rewind it, or is not a whole number, is ignored. */
|
|
139
|
+
continueFrom(issued: number): void {
|
|
140
|
+
if (Number.isSafeInteger(issued) && issued > this.seq) this.seq = issued;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean; queued?: boolean; batch?: AsyncRun["batch"] }, run: RunThunk): string {
|
|
99
144
|
this.seq += 1;
|
|
100
145
|
const id = `run-${this.seq.toString(36)}`;
|
|
101
146
|
const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
|
|
@@ -109,12 +154,16 @@ export class AsyncRunTracker {
|
|
|
109
154
|
...(meta.queued ? { queued: true } : { lastAdvanceAt: startedAt }),
|
|
110
155
|
};
|
|
111
156
|
if (meta.mutates) entry.mutates = true;
|
|
157
|
+
if (meta.batch) entry.batch = meta.batch;
|
|
112
158
|
// Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
|
|
113
159
|
// once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
|
|
114
160
|
// those views; renderers still sanitize defensively when they compose a label.
|
|
115
161
|
if (meta.label !== undefined) entry.label = sanitizeDisplayLabel(meta.label);
|
|
116
162
|
if (meta.model !== undefined) entry.model = sanitizeDisplayLabel(meta.model, "model");
|
|
117
163
|
this.runs.set(id, entry);
|
|
164
|
+
for (const cb of [...this.createListeners]) {
|
|
165
|
+
try { cb(entry); } catch { /* archival observation cannot prevent launch */ }
|
|
166
|
+
}
|
|
118
167
|
|
|
119
168
|
const settle = (): void => this.settleOnce(entry);
|
|
120
169
|
// `Promise.resolve(run(...))` routes BOTH a returned rejection AND a SYNCHRONOUS throw from
|
|
@@ -124,21 +173,17 @@ export class AsyncRunTracker {
|
|
|
124
173
|
try {
|
|
125
174
|
Promise.resolve(
|
|
126
175
|
run((s) => {
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
const prev = entry.progress;
|
|
130
|
-
const toolOutputAdvanced = s.toolOutputAt !== undefined && Number.isFinite(s.toolOutputAt)
|
|
131
|
-
&& s.toolOutputAt >= 0 && s.toolOutputAt !== prev.toolOutputAt;
|
|
132
|
-
if (!entry.queued && (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns || toolOutputAdvanced)) {
|
|
133
|
-
entry.lastAdvanceAt = this.now();
|
|
134
|
-
}
|
|
176
|
+
// The injected clock stamps the advance, never an event's own timestamp.
|
|
177
|
+
if (!entry.queued && madeHeadway(entry.progress, s)) entry.lastAdvanceAt = this.now();
|
|
135
178
|
entry.progress = s;
|
|
136
179
|
}, id),
|
|
137
180
|
)
|
|
138
181
|
.then((result) => {
|
|
139
182
|
if (entry.settled) return; // force-settled by the supervisor — drop the late natural result
|
|
140
183
|
entry.status = result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed";
|
|
141
|
-
|
|
184
|
+
// Retain scalar metadata by value: an engine reusing/mutating its result object
|
|
185
|
+
// after settlement must not rewrite this run's requested/observed effort.
|
|
186
|
+
entry.result = { ...result, usage: { ...result.usage }, ...(result.resultReceipt ? { resultReceipt: { ...result.resultReceipt } } : {}) };
|
|
142
187
|
if (!result.ok && result.error) entry.error = result.error;
|
|
143
188
|
})
|
|
144
189
|
.catch((err: unknown) => {
|
|
@@ -167,8 +212,8 @@ export class AsyncRunTracker {
|
|
|
167
212
|
entry.settledAt = this.now();
|
|
168
213
|
this.settledOrder.push(entry.id);
|
|
169
214
|
this.prune();
|
|
170
|
-
// Iterate a SNAPSHOT: a
|
|
171
|
-
// splicing the live array under the cursor would shift the next
|
|
215
|
+
// Iterate a SNAPSHOT: a listener may unsubscribe from inside its own callback, and
|
|
216
|
+
// splicing the live array under the cursor would shift the next listener past it.
|
|
172
217
|
// A listener's throw must likewise not skip the ones after it — and on the natural-settle
|
|
173
218
|
// path this runs inside an unobserved promise chain, where an escaping throw becomes a
|
|
174
219
|
// process-killing unhandled rejection.
|
|
@@ -183,15 +228,36 @@ export class AsyncRunTracker {
|
|
|
183
228
|
}
|
|
184
229
|
}
|
|
185
230
|
|
|
186
|
-
/**
|
|
231
|
+
/** A queued run acquired its concurrency slot: execution, and its stall window, start now. */
|
|
187
232
|
markAdmitted(id: string): boolean {
|
|
188
233
|
const entry = this.runs.get(id);
|
|
189
234
|
if (!entry || entry.status !== "running" || entry.settled || entry.queued !== true) return false;
|
|
190
235
|
delete entry.queued;
|
|
191
|
-
entry.lastAdvanceAt = this.now();
|
|
236
|
+
entry.startedAt = entry.lastAdvanceAt = this.now();
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Mark a running run as waiting on the operator (`label`), or release it (`undefined`). The clock
|
|
241
|
+
* restarts on release, so the human's think time is never counted as the run's own silence. */
|
|
242
|
+
setAwaiting(id: string, label: string | undefined): boolean {
|
|
243
|
+
const entry = this.runs.get(id);
|
|
244
|
+
if (!entry || entry.status !== "running" || entry.settled) return false;
|
|
245
|
+
if (label === undefined) {
|
|
246
|
+
delete entry.awaiting;
|
|
247
|
+
this.advance(id);
|
|
248
|
+
} else {
|
|
249
|
+
entry.awaiting = sanitizeStatusLabel(label, "waiting for operator", 120);
|
|
250
|
+
}
|
|
192
251
|
return true;
|
|
193
252
|
}
|
|
194
253
|
|
|
254
|
+
/** Credit headway to a running run whose own snapshot cannot show it: a council or flow aggregates its
|
|
255
|
+
* legs' progress, so one leg's advance is invisible in the sum. Queued, settled and unknown runs are left alone. */
|
|
256
|
+
advance(id: string): void {
|
|
257
|
+
const entry = this.runs.get(id);
|
|
258
|
+
if (entry && entry.status === "running" && !entry.settled && !entry.queued) entry.lastAdvanceAt = this.now();
|
|
259
|
+
}
|
|
260
|
+
|
|
195
261
|
/**
|
|
196
262
|
* Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
|
|
197
263
|
* for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
|
|
@@ -280,6 +346,22 @@ export class AsyncRunTracker {
|
|
|
280
346
|
return this.list().filter((r) => r.status === "running");
|
|
281
347
|
}
|
|
282
348
|
|
|
349
|
+
/** True when `run` may be reported: it belongs to no batch, or no member of its batch is still
|
|
350
|
+
* running. Asked of the live tracker, never counted from a delivered set, so a member collected
|
|
351
|
+
* early (`intercom result`), stopped, or failed inside launch() can never keep its batch waiting. */
|
|
352
|
+
batchSettled(run: AsyncRun): boolean {
|
|
353
|
+
const batch = run.batch;
|
|
354
|
+
return batch === undefined || !this.running().some((r) => r.batch === batch);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
onCreate(cb: (run: AsyncRun) => void): () => void {
|
|
358
|
+
this.createListeners.push(cb);
|
|
359
|
+
return () => {
|
|
360
|
+
const i = this.createListeners.indexOf(cb);
|
|
361
|
+
if (i >= 0) this.createListeners.splice(i, 1);
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
283
365
|
onComplete(cb: (run: AsyncRun) => void): () => void {
|
|
284
366
|
this.completeListeners.push(cb);
|
|
285
367
|
return () => {
|
|
@@ -287,54 +369,27 @@ export class AsyncRunTracker {
|
|
|
287
369
|
if (i >= 0) this.completeListeners.splice(i, 1);
|
|
288
370
|
};
|
|
289
371
|
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
|
|
293
|
-
* it resolves with the runs' CURRENT states, so the caller reports what settled and
|
|
294
|
-
* what is still going. Backs the `intercom wait` action (the supervisor holds its turn
|
|
295
|
-
* to collect async results it now needs before its next step).
|
|
296
|
-
*/
|
|
297
|
-
waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
|
|
298
|
-
const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
|
|
299
|
-
if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
|
|
300
|
-
return new Promise((resolve) => {
|
|
301
|
-
let off = (): void => {};
|
|
302
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
303
|
-
const finish = (): void => {
|
|
304
|
-
off();
|
|
305
|
-
if (timer) clearTimeout(timer);
|
|
306
|
-
signal?.removeEventListener("abort", finish);
|
|
307
|
-
resolve(targets);
|
|
308
|
-
};
|
|
309
|
-
off = this.onComplete(() => {
|
|
310
|
-
if (targets.every((r) => r.status !== "running")) finish();
|
|
311
|
-
});
|
|
312
|
-
timer = setTimeout(finish, timeoutMs);
|
|
313
|
-
timer.unref?.();
|
|
314
|
-
if (signal) {
|
|
315
|
-
if (signal.aborted) finish();
|
|
316
|
-
else signal.addEventListener("abort", finish, { once: true });
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
372
|
}
|
|
321
373
|
|
|
322
|
-
/**
|
|
323
|
-
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
324
|
-
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
325
|
-
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
326
|
-
* periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
|
|
327
|
-
*/
|
|
374
|
+
/** First occurrence of each run id, in order. */
|
|
328
375
|
export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
329
376
|
const byId = new Map<string, AsyncRun>();
|
|
330
377
|
for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
|
|
331
378
|
return [...byId.values()];
|
|
332
379
|
}
|
|
333
380
|
|
|
381
|
+
/** A terminal engine observation supersedes the launch choice; running/launch rows retain their snapshot. */
|
|
382
|
+
export function observedRunModel(run: Partial<Pick<AsyncRun, "status" | "result">>): string | undefined {
|
|
383
|
+
const model = run.status !== undefined && run.status !== "running" ? run.result?.modelUsed : undefined;
|
|
384
|
+
return model?.trim() ? model.split("/").map((part) => sanitizeDisplayLabel(part, "model", 256)).join("/").slice(0, 256) : undefined;
|
|
385
|
+
}
|
|
386
|
+
|
|
334
387
|
/** One identity everywhere a tracked run is rendered: the launcher's codename when present,
|
|
335
|
-
* otherwise the agent type, with the
|
|
336
|
-
export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">): string {
|
|
337
|
-
const
|
|
388
|
+
* otherwise the agent type, with the observed terminal model or launch model appended once. */
|
|
389
|
+
export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model"> & Partial<Pick<AsyncRun, "status" | "result">>): string {
|
|
390
|
+
const observed = observedRunModel(run);
|
|
391
|
+
const selected = observed ? (observed.split("/").pop() ?? observed).replace(/^claude-/, "") : run.model;
|
|
392
|
+
const model = selected === undefined ? undefined : sanitizeDisplayLabel(selected, "model", 24);
|
|
338
393
|
if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
|
|
339
394
|
const suffix = ` · ${model}`;
|
|
340
395
|
const nameLimit = Math.max(1, MAX_DISPLAY_LABEL_CHARS - suffix.length);
|
|
@@ -346,13 +401,14 @@ export function runDisplayName(run: Pick<AsyncRun, "agent" | "label" | "model">)
|
|
|
346
401
|
*
|
|
347
402
|
* Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
|
|
348
403
|
* came back in seconds or ground for two hours changes what to do next — and no completion surface
|
|
349
|
-
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing or
|
|
404
|
+
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing, inverted or not finite, so an
|
|
350
405
|
* unstamped run renders exactly as it did before instead of gaining a fabricated reading.
|
|
351
406
|
*/
|
|
352
407
|
export function runDurationLabel(run: AsyncRun): string | undefined {
|
|
353
408
|
const { startedAt, settledAt } = run;
|
|
354
|
-
if (startedAt === undefined || settledAt === undefined
|
|
355
|
-
|
|
409
|
+
if (startedAt === undefined || settledAt === undefined) return undefined;
|
|
410
|
+
const span = settledAt - startedAt;
|
|
411
|
+
return Number.isFinite(span) && span >= 0 ? formatDuration(span) : undefined;
|
|
356
412
|
}
|
|
357
413
|
|
|
358
414
|
/** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
|
|
@@ -404,20 +460,6 @@ export function boundCompletionSurface(
|
|
|
404
460
|
|
|
405
461
|
const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
|
|
406
462
|
|
|
407
|
-
/** A timeout is a status update, not an invitation to poll. The passive completion notifier is
|
|
408
|
-
* the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
|
|
409
|
-
* for intervention. */
|
|
410
|
-
export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
|
|
411
|
-
const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
412
|
-
const omitted = ids.length - visibleIds.length;
|
|
413
|
-
const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
|
|
414
|
-
return (
|
|
415
|
-
`⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
|
|
416
|
-
"Continue useful supervisor work; completion will notify you automatically. " +
|
|
417
|
-
"Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
463
|
/**
|
|
422
464
|
* The explicit overflow signal for the one case retention cannot cover: more runs settled than the
|
|
423
465
|
* tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
|
|
@@ -464,6 +506,12 @@ export function isAsyncRunStalled(run: AsyncRun, now: number, stallMs: number):
|
|
|
464
506
|
&& now - run.lastAdvanceAt >= stallMs;
|
|
465
507
|
}
|
|
466
508
|
|
|
509
|
+
/**
|
|
510
|
+
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
511
|
+
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
512
|
+
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
513
|
+
* periodic stall check never wakes the model with this digest; it files it as an operator-only card.
|
|
514
|
+
*/
|
|
467
515
|
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number; waitingForSupervisor?: ReadonlySet<string> }): string {
|
|
468
516
|
if (runs.length === 0) return "No async runs.";
|
|
469
517
|
const running = runs.filter((r) => r.status === "running").length;
|
|
@@ -478,8 +526,13 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
478
526
|
const name = runDisplayName(r);
|
|
479
527
|
const head = `[${r.id}] ${name} — ${r.status}`;
|
|
480
528
|
if (r.status === "running") {
|
|
481
|
-
|
|
529
|
+
// A tracked council/flow reports tokens only (its legs carry no turn count): "0 turns" would be false.
|
|
530
|
+
let line = `${head} (${r.progress.turns > 0 ? `${r.progress.turns} turns, ` : ""}${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
531
|
+
// Queued time is not execution time, so a queued run claims no "running for".
|
|
532
|
+
const ran = r.queued ? undefined : runDurationLabel({ ...r, ...(now !== undefined ? { settledAt: now } : {}) });
|
|
533
|
+
if (ran) line += ` · running for ${ran}`;
|
|
482
534
|
if (r.queued) line += " · queued (waiting for concurrency slot)";
|
|
535
|
+
else if (r.awaiting) line += ` · ${r.awaiting}`;
|
|
483
536
|
else if (opts?.waitingForSupervisor?.has(r.id)) line += " · waiting for supervisor";
|
|
484
537
|
else if (now !== undefined && stallMs !== undefined && isAsyncRunStalled(r, now, stallMs)) {
|
|
485
538
|
const stalledFor = now - r.lastAdvanceAt!;
|
|
@@ -504,7 +557,7 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
504
557
|
|
|
505
558
|
/**
|
|
506
559
|
* Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
|
|
507
|
-
* been surfaced as stalled, so the
|
|
560
|
+
* been surfaced as stalled, so the operator is told ONCE when a leg crosses the stall window — never
|
|
508
561
|
* every tick while it stays stalled, and never at all while it is making progress. A leg that advances
|
|
509
562
|
* again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
|
|
510
563
|
* forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
|
|
@@ -512,20 +565,6 @@ export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs
|
|
|
512
565
|
export class PeekWatcher {
|
|
513
566
|
/** runId → the `lastAdvanceAt` value at which the operator episode was reported. */
|
|
514
567
|
private readonly reportedAt = new Map<string, number>();
|
|
515
|
-
/** Runs whose one lifetime model stall wake was successfully delivered. */
|
|
516
|
-
private readonly modelWoken = new Set<string>();
|
|
517
|
-
|
|
518
|
-
firstWakeCandidates(runs: AsyncRun[]): AsyncRun[] {
|
|
519
|
-
return runs.filter((run) => !this.modelWoken.has(run.id));
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
acknowledgeModelWake(runIds: readonly string[]): void {
|
|
523
|
-
for (const id of runIds) this.modelWoken.add(id);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
retryReports(runs: readonly AsyncRun[]): void {
|
|
527
|
-
for (const run of runs) this.reportedAt.delete(run.id);
|
|
528
|
-
}
|
|
529
568
|
|
|
530
569
|
/** The running legs that have NEWLY crossed the stall window since we last reported them. */
|
|
531
570
|
poll(runs: AsyncRun[], now: number, stallMs: number, waitingForSupervisor: ReadonlySet<string> = new Set()): AsyncRun[] {
|
|
@@ -538,8 +577,8 @@ export class PeekWatcher {
|
|
|
538
577
|
this.reportedAt.delete(r.id);
|
|
539
578
|
continue;
|
|
540
579
|
}
|
|
541
|
-
if (waitingForSupervisor.has(r.id)) {
|
|
542
|
-
this.reportedAt.delete(r.id); // a live ask is not a stall; re-evaluate
|
|
580
|
+
if (waitingForSupervisor.has(r.id) || r.awaiting) {
|
|
581
|
+
this.reportedAt.delete(r.id); // a live ask or an operator decision is not a stall; re-evaluate when it settles
|
|
543
582
|
continue;
|
|
544
583
|
}
|
|
545
584
|
if (r.lastAdvanceAt === undefined) continue;
|
|
@@ -557,35 +596,9 @@ export class PeekWatcher {
|
|
|
557
596
|
/** Forget all tracked state (session start / reload hygiene). */
|
|
558
597
|
reset(): void {
|
|
559
598
|
this.reportedAt.clear();
|
|
560
|
-
this.modelWoken.clear();
|
|
561
599
|
}
|
|
562
600
|
}
|
|
563
601
|
|
|
564
|
-
/**
|
|
565
|
-
* The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
|
|
566
|
-
* when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
|
|
567
|
-
* signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
|
|
568
|
-
* patience-first, and intervention means asking the leg — never reaching into its environment to check.
|
|
569
|
-
*/
|
|
570
|
-
export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
|
|
571
|
-
if (stuck.length === 0) return "";
|
|
572
|
-
const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
573
|
-
const lines = visibleStuck.map((r) => {
|
|
574
|
-
const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
|
|
575
|
-
return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
576
|
-
});
|
|
577
|
-
const omitted = stuck.length - visibleStuck.length;
|
|
578
|
-
if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
|
|
579
|
-
lines.push(PROGRESS_TOKEN_NOTE);
|
|
580
|
-
return [
|
|
581
|
-
`${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
|
|
582
|
-
...lines,
|
|
583
|
-
"A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
|
|
584
|
-
"If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
|
|
585
|
-
"wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
|
|
586
|
-
].join("\n");
|
|
587
|
-
}
|
|
588
|
-
|
|
589
602
|
/** The operator-only periodic status card. It carries the progress digest without model instructions. */
|
|
590
603
|
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number; waitingForSupervisor?: ReadonlySet<string>; reason?: "routine" | "stall" }): string {
|
|
591
604
|
const reason = opts.reason ?? "routine";
|
|
@@ -597,20 +610,29 @@ export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: num
|
|
|
597
610
|
* burst into a single message is what keeps completions from piling up as separate queued
|
|
598
611
|
* follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
|
|
599
612
|
* supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
|
|
600
|
-
* sub-agent text so the supervisor treats it as data, never as instructions.
|
|
613
|
+
* sub-agent text so the supervisor treats it as data, never as instructions. `bounded: false` lifts
|
|
614
|
+
* every cap, for the headless inline return where the caller is parked in the call and nothing is injected.
|
|
601
615
|
*/
|
|
602
|
-
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
|
|
616
|
+
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string, opts: { bounded?: boolean } = {}): string {
|
|
603
617
|
const done = runs.filter((r) => r.status === "done");
|
|
604
618
|
const failed = runs.filter((r) => r.status === "failed");
|
|
605
619
|
const stopped = runs.filter((r) => r.status === "stopped");
|
|
606
620
|
// Share the notification budget across every leg that can contribute payload. This keeps a
|
|
607
621
|
// large fan-out fair: the first result cannot consume the whole follow-up while later statuses
|
|
608
622
|
// are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
|
|
623
|
+
const bounded = opts.bounded !== false;
|
|
624
|
+
const cap = (chars: number): number => (bounded ? chars : Number.POSITIVE_INFINITY);
|
|
609
625
|
const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
|
|
610
|
-
const fairRunChars = Math.min(
|
|
626
|
+
const fairRunChars = cap(Math.min(
|
|
611
627
|
MAX_COMPLETION_RUN_CHARS,
|
|
612
628
|
Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
|
|
613
|
-
);
|
|
629
|
+
));
|
|
630
|
+
// Each failed leg is clipped on its own line, from a budget of its own: every id and cause stays
|
|
631
|
+
// listed however wide the fan-out, and the drill-down names a real run.
|
|
632
|
+
const failureChars = cap(Math.min(
|
|
633
|
+
MAX_COMPLETION_RUN_CHARS,
|
|
634
|
+
Math.max(200, Math.floor(MAX_COMPLETION_REPORT_CHARS / (4 * Math.max(1, failed.length)))),
|
|
635
|
+
));
|
|
614
636
|
// Failures are always reported — the supervisor must know so it can adjust or tell
|
|
615
637
|
// the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
|
|
616
638
|
// (an identical delegation that failed twice is vetoed before it spawns), not by
|
|
@@ -619,6 +641,25 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
619
641
|
const stoppedCount = stopped.length > 0 ? `, ${stopped.length} stopped` : "";
|
|
620
642
|
const head = `[pi-persona] ${runs.length} async run${runs.length === 1 ? "" : "s"} settled — ${done.length} done, ${failed.length} failed${stoppedCount}`;
|
|
621
643
|
const blocks: string[] = [head];
|
|
644
|
+
const models = runs.flatMap((run) => {
|
|
645
|
+
const model = observedRunModel(run);
|
|
646
|
+
return model ? [`${run.id}: Observed model: ${model}`] : [];
|
|
647
|
+
});
|
|
648
|
+
if (models.length > 0) blocks.push(`\n${models.join("\n")}`);
|
|
649
|
+
const receipts = runs.slice(0, MAX_ASYNC_STATUS_ROWS).flatMap((run) => {
|
|
650
|
+
const hint = resultReceiptHint(run.resultReceipt);
|
|
651
|
+
return hint ? [`${run.id}: ${hint}`] : [];
|
|
652
|
+
});
|
|
653
|
+
if (receipts.length > 0) blocks.push(`\nTerminal reports (intercom results lists all retained references):\n${receipts.join("\n")}`);
|
|
654
|
+
// Common result metadata, outside untrusted worker prose, on inline and background paths.
|
|
655
|
+
// A host effort observation is a Pi request setting, not a measure of provider cognition.
|
|
656
|
+
const efforts = runs.flatMap((r) => {
|
|
657
|
+
const requested = r.result?.thinkingRequested;
|
|
658
|
+
const observed = r.result?.thinkingEffective;
|
|
659
|
+
if (!isThinkingLevel(requested) && !isThinkingLevel(observed)) return [];
|
|
660
|
+
return [`${r.id}: requested ${isThinkingLevel(requested) ? requested : "unknown"}; observed Pi effort ${isThinkingLevel(observed) ? observed : "unknown"}`];
|
|
661
|
+
});
|
|
662
|
+
if (efforts.length > 0) blocks.push(`\nThinking (not provider cognition):\n${efforts.join("\n")}`);
|
|
622
663
|
for (const r of done) {
|
|
623
664
|
// Only fence REAL output — an empty/whitespace result gets a plain "(no output)", never an
|
|
624
665
|
// empty <fence></fence> shell (there's nothing untrusted to guard, and the empty block reads
|
|
@@ -640,9 +681,9 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
640
681
|
}
|
|
641
682
|
}
|
|
642
683
|
if (failed.length > 0) {
|
|
643
|
-
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
|
|
684
|
+
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${clipRunOutput(r.error ?? "(no detail)", r.id, failureChars, "failure detail")}`).join("\n");
|
|
644
685
|
blocks.push(
|
|
645
|
-
`\n❌ ${failed.length} failed:\n${fence(
|
|
686
|
+
`\n❌ ${failed.length} failed:\n${fence(reasons)}\n\n` +
|
|
646
687
|
"Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
|
|
647
688
|
"If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
|
|
648
689
|
"Do not re-issue the same failing delegation repeatedly.",
|
|
@@ -656,17 +697,24 @@ export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) =>
|
|
|
656
697
|
}
|
|
657
698
|
}
|
|
658
699
|
}
|
|
659
|
-
|
|
700
|
+
const report = blocks.join("\n");
|
|
701
|
+
if (!bounded) return report;
|
|
702
|
+
// The cap cuts whole legs from the middle of a wide fan-out. Name every run in the tail, which the
|
|
703
|
+
// cut keeps, so a cut leg is still fetchable by id instead of silently missing.
|
|
704
|
+
const named = report.length > MAX_COMPLETION_REPORT_CHARS
|
|
705
|
+
? `${report}\n\nRun ids in this report: ${runs.slice(0, MAX_ASYNC_STATUS_ROWS).map((r) => r.id).join(", ")}. Saved reports: intercom { action:"results" }; use result { ref, offset, limit } even if live handles were dropped.`
|
|
706
|
+
: report;
|
|
707
|
+
return boundCompletionSurface(named);
|
|
660
708
|
}
|
|
661
709
|
|
|
662
710
|
/**
|
|
663
711
|
* The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
|
|
664
|
-
* carries a surrender marker, `scan` returns the note to append (else undefined).
|
|
665
|
-
* completion notifier
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
712
|
+
* carries a surrender marker, `scan` returns the note to append (else undefined). The background
|
|
713
|
+
* completion notifier renders through this, so a blocked leg gets the counterweight on the
|
|
714
|
+
* automatic follow-up — the sync `tool_result` hook never sees a background run. Only DONE legs
|
|
715
|
+
* are scanned: a FAILED leg is already surfaced as a failure by {@link buildCompletionReport},
|
|
716
|
+
* so the guard and the append agree (no done-vs-all mismatch). `scan` is injected
|
|
717
|
+
* (PersistenceNudge.scan) to keep this module decoupled from the nudge.
|
|
670
718
|
*/
|
|
671
719
|
export function renderCompletion(
|
|
672
720
|
runs: AsyncRun[],
|
|
@@ -691,9 +739,6 @@ export const MAX_EMPTY_RENDER_RETRIES = 3;
|
|
|
691
739
|
export interface IdleNotifierDeps<T> {
|
|
692
740
|
/** Whether the supervisor is idle (not streaming a turn). */
|
|
693
741
|
isIdle: () => boolean;
|
|
694
|
-
/** Legacy host-queue probe. Retained for source compatibility, but deliberately ignored: when
|
|
695
|
-
* the supervisor is idle, starting a turn is what drains any orphaned host follow-ups. */
|
|
696
|
-
hasPending?: () => boolean;
|
|
697
742
|
/** Deliver the coalesced message. Async rejection is treated exactly like a synchronous throw:
|
|
698
743
|
* the batch is restored at the front of the queue and retried. */
|
|
699
744
|
deliver: (message: string) => void | Promise<void>;
|
|
@@ -720,6 +765,10 @@ export interface IdleNotifierDeps<T> {
|
|
|
720
765
|
* drain keeps pace with small items instead of being pinned to the worst case. Either form is
|
|
721
766
|
* clamped to at least one item: a queue must always drain. */
|
|
722
767
|
maxBatchItems?: number | ((pending: readonly T[]) => number);
|
|
768
|
+
/** Whether a buffered item may be delivered yet. A held item stays buffered (peekable, discardable)
|
|
769
|
+
* and is not polled: it ships on the first flush after the `notify()`/`kick()` that made it ready.
|
|
770
|
+
* Unset ⇒ everything is ready. */
|
|
771
|
+
isReady?: (item: T) => boolean;
|
|
723
772
|
/** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
|
|
724
773
|
now?: () => number;
|
|
725
774
|
}
|
|
@@ -728,7 +777,8 @@ export interface IdleNotifierDeps<T> {
|
|
|
728
777
|
* Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
|
|
729
778
|
* A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
|
|
730
779
|
* Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
|
|
731
|
-
* Used for async-run completions and child
|
|
780
|
+
* Used for async-run completions (a batch's members held until the last one settles) and child
|
|
781
|
+
* intercom asks.
|
|
732
782
|
*/
|
|
733
783
|
export class IdleCoalescingNotifier<T> {
|
|
734
784
|
private readonly deps: IdleNotifierDeps<T>;
|
|
@@ -784,15 +834,15 @@ export class IdleCoalescingNotifier<T> {
|
|
|
784
834
|
}
|
|
785
835
|
|
|
786
836
|
/** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
|
|
787
|
-
* follow-up hasn't flushed yet). Lets `intercom
|
|
788
|
-
* settle→deliver gap instead of reporting nothing
|
|
789
|
-
* the pending flush won't re-deliver it. */
|
|
837
|
+
* follow-up hasn't flushed yet). Lets `intercom peek` surface results in the
|
|
838
|
+
* settle→deliver gap instead of reporting nothing. Peeking does not collect: only an explicit
|
|
839
|
+
* `result` `discard`s the item so the pending flush won't re-deliver it. */
|
|
790
840
|
peekPending(): T[] {
|
|
791
841
|
return [...this.pending];
|
|
792
842
|
}
|
|
793
843
|
|
|
794
844
|
/** Drop buffered items matching `pred` — e.g. results the supervisor already collected
|
|
795
|
-
*
|
|
845
|
+
* explicitly (intercom `result`), which must not be re-delivered as a follow-up. */
|
|
796
846
|
discard(pred: (item: T) => boolean): void {
|
|
797
847
|
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
798
848
|
if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
|
|
@@ -816,7 +866,8 @@ export class IdleCoalescingNotifier<T> {
|
|
|
816
866
|
flushIfIdle(): Promise<void> {
|
|
817
867
|
if (this.flushing) return this.flushing;
|
|
818
868
|
this.clearArmedTimer();
|
|
819
|
-
|
|
869
|
+
const ready = this.readyItems();
|
|
870
|
+
if (ready.length === 0) return Promise.resolve();
|
|
820
871
|
|
|
821
872
|
const generation = this.generation;
|
|
822
873
|
let batch: T[] | undefined;
|
|
@@ -841,8 +892,9 @@ export class IdleCoalescingNotifier<T> {
|
|
|
841
892
|
}
|
|
842
893
|
}
|
|
843
894
|
|
|
844
|
-
const take = this.maxBatchItems === undefined ?
|
|
845
|
-
batch =
|
|
895
|
+
const take = this.maxBatchItems === undefined ? ready.length : Math.max(1, Math.floor(this.maxBatchItems(ready)));
|
|
896
|
+
batch = ready.slice(0, take);
|
|
897
|
+
for (const item of batch) this.pending.splice(this.pending.indexOf(item), 1);
|
|
846
898
|
const message = this.deps.render(batch);
|
|
847
899
|
if (!message) {
|
|
848
900
|
// Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
|
|
@@ -917,8 +969,14 @@ export class IdleCoalescingNotifier<T> {
|
|
|
917
969
|
this.handle = undefined;
|
|
918
970
|
}
|
|
919
971
|
|
|
972
|
+
/** The buffered items that may be delivered now, in order. */
|
|
973
|
+
private readyItems(): T[] {
|
|
974
|
+
const { isReady } = this.deps;
|
|
975
|
+
return isReady ? this.pending.filter(isReady) : [...this.pending];
|
|
976
|
+
}
|
|
977
|
+
|
|
920
978
|
private arm(ms: number): void {
|
|
921
|
-
if (this.handle !== undefined || this.
|
|
979
|
+
if (this.handle !== undefined || this.readyItems().length === 0) return;
|
|
922
980
|
let scheduled!: unknown;
|
|
923
981
|
scheduled = this.deps.setTimer(() => {
|
|
924
982
|
if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
|