@aeondave/pi-persona 1.15.2
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 +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async run tracking (v0.3). A supervisor can launch a child in the background,
|
|
3
|
+
* keep working, peek its live progress, and be notified on completion — without
|
|
4
|
+
* blocking the turn. Backend-agnostic: the `run` thunk is injected (real engine
|
|
5
|
+
* or stub), so this is fully unit-testable.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: live *steering* of a running child-process agent (resume/redirect) needs
|
|
8
|
+
* the cross-process broker (v0.5) or the InProcessEngine (v0.4). This module
|
|
9
|
+
* covers launch + peek + completion-notify (the parts that work on `pi -p`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ProgressSnapshot } from "./stream.ts";
|
|
13
|
+
import type { AgentResult } from "../orchestration/types.ts";
|
|
14
|
+
import { MAX_DISPLAY_LABEL_CHARS, sanitizeDisplayLabel } from "../core/display-label.ts";
|
|
15
|
+
import { compactTokens } from "../core/format.ts";
|
|
16
|
+
import { formatDuration } from "../core/time.ts";
|
|
17
|
+
|
|
18
|
+
export { MAX_DISPLAY_LABEL_CHARS } from "../core/display-label.ts";
|
|
19
|
+
|
|
20
|
+
/** "possibly stuck" — the soft stall signal shared by the supervisor alert and the UI badge. It is
|
|
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
|
|
23
|
+
* spell. Purely advisory (no auto-abort); the idle watchdog + token budget are the enforcing backstops. */
|
|
24
|
+
export const STALL_FLAG_MS = 90_000;
|
|
25
|
+
|
|
26
|
+
const PROGRESS_TOKEN_NOTE = "Cumulative tokens show total input + output so far, not current context occupancy.";
|
|
27
|
+
|
|
28
|
+
export interface AsyncRun {
|
|
29
|
+
id: string;
|
|
30
|
+
agent: string;
|
|
31
|
+
task: string;
|
|
32
|
+
status: "running" | "done" | "failed" | "stopped";
|
|
33
|
+
progress: ProgressSnapshot;
|
|
34
|
+
result?: AgentResult;
|
|
35
|
+
error?: string;
|
|
36
|
+
/** The codename the launcher gave this run (e.g. "atlas-static") — the SAME name shown on the
|
|
37
|
+
* agent-tree node for this run. When set, it's the canonical display name (over the bare
|
|
38
|
+
* `agent` type); absent for call sites that don't carry one (falls back to `agent`). */
|
|
39
|
+
label?: string;
|
|
40
|
+
/** The already-shortened model (e.g. "sonnet") this run was launched with — kept pre-shortened
|
|
41
|
+
* so this module needs no import from delegate.ts just to render it. */
|
|
42
|
+
model?: string;
|
|
43
|
+
/** Clock (ms) this run was launched at, and the clock it settled at — the pair the supervisor's
|
|
44
|
+
* "that leg took 2m 5s" reading is computed from. Both come from the tracker's INJECTED clock,
|
|
45
|
+
* so a test can settle a two-hour run without waiting. Optional because runs also reach the
|
|
46
|
+
* renderers from call sites that never stamped them; absent ⇒ no duration is claimed. */
|
|
47
|
+
startedAt?: number;
|
|
48
|
+
settledAt?: number;
|
|
49
|
+
/** Clock (ms) of the last time `progress` actually *advanced* (output grew, or turns/tokens
|
|
50
|
+
* rose) — NOT merely the last event. A worker looping without headway keeps emitting but
|
|
51
|
+
* never bumps this, so `now - lastAdvanceAt` is what tells "working" from "stuck". */
|
|
52
|
+
lastAdvanceAt?: number;
|
|
53
|
+
/** Internal bookkeeping: true once the completion listeners have fired for this entry. Guards a
|
|
54
|
+
* force-settle racing the run thunk's natural resolution so listeners fire exactly once. Lives on
|
|
55
|
+
* the entry (not a side Set) so it survives pruning and the `launch` closure still sees it. */
|
|
56
|
+
settled?: boolean;
|
|
57
|
+
/** True once the supervisor has explicitly fetched this run's FULL payload. Retention then treats
|
|
58
|
+
* the retained copy as spare and evicts it before a result nobody has read yet. */
|
|
59
|
+
collected?: boolean;
|
|
60
|
+
/** True when the launcher classified this leg as a potential workspace MUTATION (its tool grant is
|
|
61
|
+
* not provably read-only) — what a persona's fresh-verification policy gates on. Carried on the
|
|
62
|
+
* entry rather than in a side Set at the call site: a thunk that throws synchronously settles the
|
|
63
|
+
* run INSIDE {@link AsyncRunTracker.launch}, before the caller holds the id, so a registration
|
|
64
|
+
* performed after launch() returns would re-insert an already-dead run and leak it. */
|
|
65
|
+
mutates?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The run's id is passed in so the launcher can wire a steer handle keyed by it. */
|
|
69
|
+
export type RunThunk = (onProgress: (snapshot: ProgressSnapshot) => void, runId: string) => Promise<AgentResult>;
|
|
70
|
+
|
|
71
|
+
/** How many uncollected evictions the tracker remembers, so a later `result` can answer "dropped
|
|
72
|
+
* under pressure" instead of an indistinguishable "never existed". Bounded: a session that drops
|
|
73
|
+
* thousands of runs only ever needs to explain the recent ones. */
|
|
74
|
+
export const MAX_DROPPED_IDS = 256;
|
|
75
|
+
|
|
76
|
+
export class AsyncRunTracker {
|
|
77
|
+
private readonly runs = new Map<string, AsyncRun>();
|
|
78
|
+
/** Ids of settled runs in SETTLE order — the eviction queue {@link prune} drains from the front.
|
|
79
|
+
* `runs` is keyed in LAUNCH order, which stops matching once legs finish out of order. */
|
|
80
|
+
private readonly settledOrder: string[] = [];
|
|
81
|
+
/** Ids whose payload was evicted while STILL UNCOLLECTED, oldest first (insertion-ordered Set). */
|
|
82
|
+
private readonly droppedIds = new Set<string>();
|
|
83
|
+
private readonly completeListeners: Array<(run: AsyncRun) => void> = [];
|
|
84
|
+
private seq = 0;
|
|
85
|
+
private readonly maxRetained: number;
|
|
86
|
+
private readonly now: () => number;
|
|
87
|
+
|
|
88
|
+
/** `now` is injected so the stall clock is deterministic under test (real time by default).
|
|
89
|
+
* `maxRetained` (default 25 — today's hardcoded bound) is the config-driven PI_PERSONA_ASYNC_RETAIN
|
|
90
|
+
* knob's landing spot: how many settled-but-uncollected runs survive before FIFO-eviction. */
|
|
91
|
+
constructor(opts?: { now?: () => number; maxRetained?: number }) {
|
|
92
|
+
this.now = opts?.now ?? ((): number => Date.now());
|
|
93
|
+
this.maxRetained = opts?.maxRetained ?? 25;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
launch(meta: { agent: string; task: string; label?: string; model?: string; mutates?: boolean }, run: RunThunk): string {
|
|
97
|
+
this.seq += 1;
|
|
98
|
+
const id = `run-${this.seq.toString(36)}`;
|
|
99
|
+
const startedAt = this.now(); // one clock read, so the launch and stall stamps cannot disagree
|
|
100
|
+
const entry: AsyncRun = {
|
|
101
|
+
id,
|
|
102
|
+
agent: meta.agent,
|
|
103
|
+
task: meta.task,
|
|
104
|
+
status: "running",
|
|
105
|
+
progress: { output: "", turns: 0, tokens: 0 },
|
|
106
|
+
startedAt,
|
|
107
|
+
lastAdvanceAt: startedAt,
|
|
108
|
+
};
|
|
109
|
+
if (meta.mutates) entry.mutates = true;
|
|
110
|
+
// Labels/models are rendered as trusted status metadata on several compact surfaces. Normalize
|
|
111
|
+
// once at the tracker boundary so a caller cannot smuggle multiline instructions into any of
|
|
112
|
+
// those views; renderers still sanitize defensively when they compose a label.
|
|
113
|
+
if (meta.label !== undefined) entry.label = sanitizeDisplayLabel(meta.label);
|
|
114
|
+
if (meta.model !== undefined) entry.model = sanitizeDisplayLabel(meta.model, "model");
|
|
115
|
+
this.runs.set(id, entry);
|
|
116
|
+
|
|
117
|
+
const settle = (): void => this.settleOnce(entry);
|
|
118
|
+
// `Promise.resolve(run(...))` routes BOTH a returned rejection AND a SYNCHRONOUS throw from
|
|
119
|
+
// the thunk (e.g. the engine throwing before it returns its promise) through the same failure
|
|
120
|
+
// path, so onComplete always fires — otherwise the entry would be stuck "running" forever and
|
|
121
|
+
// its stop/steer handle would leak.
|
|
122
|
+
try {
|
|
123
|
+
Promise.resolve(
|
|
124
|
+
run((s) => {
|
|
125
|
+
// Only a REAL advance (more output, or a higher turn/token count) resets the stall
|
|
126
|
+
// clock — a repeated identical snapshot from a spinning worker must not mask a stall.
|
|
127
|
+
const prev = entry.progress;
|
|
128
|
+
if (s.output.length > prev.output.length || s.tokens > prev.tokens || s.turns > prev.turns) {
|
|
129
|
+
entry.lastAdvanceAt = this.now();
|
|
130
|
+
}
|
|
131
|
+
entry.progress = s;
|
|
132
|
+
}, id),
|
|
133
|
+
)
|
|
134
|
+
.then((result) => {
|
|
135
|
+
if (entry.settled) return; // force-settled by the supervisor — drop the late natural result
|
|
136
|
+
entry.status = result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed";
|
|
137
|
+
entry.result = result;
|
|
138
|
+
if (!result.ok && result.error) entry.error = result.error;
|
|
139
|
+
})
|
|
140
|
+
.catch((err: unknown) => {
|
|
141
|
+
if (entry.settled) return;
|
|
142
|
+
entry.status = "failed";
|
|
143
|
+
entry.error = err instanceof Error ? err.message : String(err);
|
|
144
|
+
})
|
|
145
|
+
.finally(settle);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
entry.status = "failed";
|
|
148
|
+
entry.error = err instanceof Error ? err.message : String(err);
|
|
149
|
+
settle();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Fire the completion listeners for `entry` exactly once. Both the natural resolution of the run
|
|
156
|
+
* thunk and a supervisor {@link forceSettle} route through here, so a race between them never
|
|
157
|
+
* double-notifies (which would double-clean handles and duplicate the completion report). */
|
|
158
|
+
private settleOnce(entry: AsyncRun): void {
|
|
159
|
+
if (entry.settled) return;
|
|
160
|
+
entry.settled = true;
|
|
161
|
+
// Stamped HERE, not on the result: this is the one point both the natural resolution and a
|
|
162
|
+
// forceSettle pass through, so every settled run gets a duration by the same rule.
|
|
163
|
+
entry.settledAt = this.now();
|
|
164
|
+
this.settledOrder.push(entry.id);
|
|
165
|
+
this.prune();
|
|
166
|
+
// Iterate a SNAPSHOT: a waitFor join unsubscribes from inside its own listener, and
|
|
167
|
+
// splicing the live array under the cursor would shift the next join's listener past it.
|
|
168
|
+
// A listener's throw must likewise not skip the ones after it — and on the natural-settle
|
|
169
|
+
// path this runs inside an unobserved promise chain, where an escaping throw becomes a
|
|
170
|
+
// process-killing unhandled rejection.
|
|
171
|
+
for (const cb of [...this.completeListeners]) {
|
|
172
|
+
try {
|
|
173
|
+
cb(entry);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (process.env.PI_PERSONA_DEBUG) {
|
|
176
|
+
process.stderr.write(`[pi-persona] async completion listener threw for ${entry.id}: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Force a still-"running" run to settle as stopped and fire its completion listeners. Escape hatch
|
|
184
|
+
* for a run whose hard-stop handle is gone (consumed by a prior abort, or never registered) while
|
|
185
|
+
* the tracker still shows it running — otherwise the supervisor's `stop` reports "no running async
|
|
186
|
+
* run" for a run its own check-in reports as running (an unkillable ghost that keeps burning the
|
|
187
|
+
* check-in budget). Idempotent; a no-op on an unknown or already-settled run. The late natural
|
|
188
|
+
* result from the underlying thunk is then dropped (guarded in {@link launch}).
|
|
189
|
+
*/
|
|
190
|
+
forceSettle(id: string, error: string): boolean {
|
|
191
|
+
const entry = this.runs.get(id);
|
|
192
|
+
if (!entry || entry.status !== "running" || entry.settled) return false;
|
|
193
|
+
entry.status = "stopped";
|
|
194
|
+
entry.error = error;
|
|
195
|
+
this.settleOnce(entry);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Keep the RETAINED (settled) runs bounded, evicting the least recently settled first.
|
|
201
|
+
*
|
|
202
|
+
* Two invariants this must not violate. A still-running entry is never evictable — its
|
|
203
|
+
* stop/steer handles and live peek hang off it — so bounding the WHOLE map would make the run
|
|
204
|
+
* that just settled the only eviction candidate whenever more legs are in flight than
|
|
205
|
+
* `maxRetained`, destroying its payload before {@link settleOnce} has even told the supervisor
|
|
206
|
+
* to fetch it. And "oldest" means oldest SETTLED, not oldest launched: legs finish out of launch
|
|
207
|
+
* order, so launch-order eviction can drop a run seconds after its completion follow-up pointed
|
|
208
|
+
* the supervisor at it. The run settling right now is appended last, hence evicted last.
|
|
209
|
+
*
|
|
210
|
+
* A bound this small cannot promise every payload survives: a fan-out wider than `maxRetained`
|
|
211
|
+
* settles more results than there is room for, whoever wins the ordering. So retention is
|
|
212
|
+
* preference plus honesty — a run the supervisor has already READ is evicted before one nobody
|
|
213
|
+
* has, and an eviction that does destroy an uncollected payload is recorded ({@link wasDropped})
|
|
214
|
+
* so the tool can say the result was dropped instead of reporting the id as unknown.
|
|
215
|
+
*/
|
|
216
|
+
private prune(): void {
|
|
217
|
+
while (this.settledOrder.length > this.maxRetained) {
|
|
218
|
+
const collectedAt = this.settledOrder.findIndex((id) => this.runs.get(id)?.collected === true);
|
|
219
|
+
const at = collectedAt >= 0 ? collectedAt : 0;
|
|
220
|
+
const [id] = this.settledOrder.splice(at, 1) as [string];
|
|
221
|
+
if (collectedAt < 0) this.recordDrop(id);
|
|
222
|
+
this.runs.delete(id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private recordDrop(id: string): void {
|
|
227
|
+
this.droppedIds.add(id);
|
|
228
|
+
// Insertion-ordered, so the first key is the oldest drop we still remember.
|
|
229
|
+
if (this.droppedIds.size > MAX_DROPPED_IDS) this.droppedIds.delete(this.droppedIds.values().next().value as string);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Record that the supervisor has read this run's full payload (an explicit `intercom result`).
|
|
233
|
+
* Its retained copy becomes the cheapest thing to evict when the bound is next hit. */
|
|
234
|
+
markCollected(id: string): void {
|
|
235
|
+
const entry = this.runs.get(id);
|
|
236
|
+
if (entry) entry.collected = true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Did this id name a run whose payload was evicted before anyone collected it? Distinguishes a
|
|
240
|
+
* result lost to retention pressure from an id that never existed — the two are the same
|
|
241
|
+
* `peek() === undefined` otherwise, and only one of them deserves an explanation. */
|
|
242
|
+
wasDropped(id: string): boolean {
|
|
243
|
+
return this.droppedIds.has(id);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The retention bound in force, quoted back to the supervisor when a payload is dropped. */
|
|
247
|
+
get retention(): number {
|
|
248
|
+
return this.maxRetained;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The still-running legs that may MUTATE the workspace (what a persona's cross-call
|
|
252
|
+
* fresh-verification gate holds a declared verifier against). Derived from live status on every
|
|
253
|
+
* call, so a settled leg can never linger as a phantom writer. */
|
|
254
|
+
writers(): AsyncRun[] {
|
|
255
|
+
return this.running().filter((r) => r.mutates === true);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
peek(id: string): AsyncRun | undefined {
|
|
259
|
+
return this.runs.get(id);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
list(): AsyncRun[] {
|
|
263
|
+
return [...this.runs.values()];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
running(): AsyncRun[] {
|
|
267
|
+
return this.list().filter((r) => r.status === "running");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
onComplete(cb: (run: AsyncRun) => void): () => void {
|
|
271
|
+
this.completeListeners.push(cb);
|
|
272
|
+
return () => {
|
|
273
|
+
const i = this.completeListeners.indexOf(cb);
|
|
274
|
+
if (i >= 0) this.completeListeners.splice(i, 1);
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Wait (join) until every listed run settles, or `timeoutMs` elapses — never rejects:
|
|
280
|
+
* it resolves with the runs' CURRENT states, so the caller reports what settled and
|
|
281
|
+
* what is still going. Backs the `intercom wait` action (the supervisor holds its turn
|
|
282
|
+
* to collect async results it now needs before its next step).
|
|
283
|
+
*/
|
|
284
|
+
waitFor(ids: string[], timeoutMs: number, signal?: AbortSignal): Promise<AsyncRun[]> {
|
|
285
|
+
const targets = ids.map((id) => this.runs.get(id)).filter((r): r is AsyncRun => r !== undefined);
|
|
286
|
+
if (targets.every((r) => r.status !== "running")) return Promise.resolve(targets);
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
let off = (): void => {};
|
|
289
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
290
|
+
const finish = (): void => {
|
|
291
|
+
off();
|
|
292
|
+
if (timer) clearTimeout(timer);
|
|
293
|
+
signal?.removeEventListener("abort", finish);
|
|
294
|
+
resolve(targets);
|
|
295
|
+
};
|
|
296
|
+
off = this.onComplete(() => {
|
|
297
|
+
if (targets.every((r) => r.status !== "running")) finish();
|
|
298
|
+
});
|
|
299
|
+
timer = setTimeout(finish, timeoutMs);
|
|
300
|
+
timer.unref?.();
|
|
301
|
+
if (signal) {
|
|
302
|
+
if (signal.aborted) finish();
|
|
303
|
+
else signal.addEventListener("abort", finish, { once: true });
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A compact ProgressView digest of async runs — the full on-demand `/peek` view, and the body the
|
|
311
|
+
* routine check-in wraps ({@link buildCheckIn}). When `opts` carries the current clock + a stall
|
|
312
|
+
* window, a RUNNING run that hasn't advanced within that window is flagged "possibly stuck". The
|
|
313
|
+
* periodic FAST wakeup does NOT send this digest — it sends the focused {@link buildPeekAlert}.
|
|
314
|
+
*/
|
|
315
|
+
export function dedupeRunsById(runs: AsyncRun[]): AsyncRun[] {
|
|
316
|
+
const byId = new Map<string, AsyncRun>();
|
|
317
|
+
for (const r of runs) if (!byId.has(r.id)) byId.set(r.id, r);
|
|
318
|
+
return [...byId.values()];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One identity everywhere a tracked run is rendered: the launcher's codename when present,
|
|
322
|
+
* otherwise the agent type, with the already-shortened model appended once. */
|
|
323
|
+
function runDisplayName(run: AsyncRun): string {
|
|
324
|
+
const model = run.model === undefined ? undefined : sanitizeDisplayLabel(run.model, "model", 24);
|
|
325
|
+
if (model === undefined) return sanitizeDisplayLabel(run.label ?? run.agent);
|
|
326
|
+
const suffix = ` · ${model}`;
|
|
327
|
+
const nameLimit = Math.max(1, MAX_DISPLAY_LABEL_CHARS - suffix.length);
|
|
328
|
+
return `${sanitizeDisplayLabel(run.label ?? run.agent, "agent", nameLimit)}${suffix}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* How long a settled leg actually took, or undefined when this run carries no usable stamps.
|
|
333
|
+
*
|
|
334
|
+
* Wall time is the supervisor's cheapest signal about a delegation — whether the leg it is reading
|
|
335
|
+
* came back in seconds or ground for two hours changes what to do next — and no completion surface
|
|
336
|
+
* used to say it. Undefined (rather than "?" or "0s") when the pair is missing or inverted, so an
|
|
337
|
+
* unstamped run renders exactly as it did before instead of gaining a fabricated reading.
|
|
338
|
+
*/
|
|
339
|
+
export function runDurationLabel(run: AsyncRun): string | undefined {
|
|
340
|
+
const { startedAt, settledAt } = run;
|
|
341
|
+
if (startedAt === undefined || settledAt === undefined || settledAt < startedAt) return undefined;
|
|
342
|
+
return formatDuration(settledAt - startedAt);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The " in 2m 5s" / " after 2m 5s" fragment for a settled leg, or "" when it has no duration. */
|
|
346
|
+
function durationSuffix(run: AsyncRun, preposition: "in" | "after"): string {
|
|
347
|
+
const label = runDurationLabel(run);
|
|
348
|
+
return label === undefined ? "" : ` ${preposition} ${label}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Completion reports are automatic follow-ups, so they must remain bounded even when a healthy
|
|
352
|
+
// child returns a very large report. The full payload stays available through the result action;
|
|
353
|
+
// these limits only govern the notification/digest surface.
|
|
354
|
+
export const MAX_PARTIAL_OUTPUT_CHARS = 12_000;
|
|
355
|
+
export const MAX_COMPLETION_REPORT_CHARS = 12_000;
|
|
356
|
+
export const MAX_COMPLETION_RUN_CHARS = Math.min(MAX_PARTIAL_OUTPUT_CHARS, 4_000);
|
|
357
|
+
export const MAX_ASYNC_STATUS_ROWS = 64;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Hard-cap a completion surface without cutting through a line. Fenced sub-agent payload lines
|
|
361
|
+
* start with `> `; slicing one mid-line would turn the suffix back into apparently trusted prose.
|
|
362
|
+
* Keeping only complete head/tail lines preserves that trust boundary while retaining useful
|
|
363
|
+
* status metadata and the most recent guidance.
|
|
364
|
+
*/
|
|
365
|
+
export function boundCompletionSurface(
|
|
366
|
+
text: string,
|
|
367
|
+
omission = `completion report truncated at ${MAX_COMPLETION_REPORT_CHARS} characters; open individual runs with intercom { action:"result", to:"<run-id>" }`,
|
|
368
|
+
): string {
|
|
369
|
+
if (text.length <= MAX_COMPLETION_REPORT_CHARS) return text;
|
|
370
|
+
const normalizedOmission = omission
|
|
371
|
+
.replace(/\r\n?/g, " ")
|
|
372
|
+
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
|
|
373
|
+
.replace(/\s+/g, " ")
|
|
374
|
+
.trim();
|
|
375
|
+
const maxOmissionChars = 512;
|
|
376
|
+
const boundedOmission = normalizedOmission.length > maxOmissionChars
|
|
377
|
+
? `${normalizedOmission.slice(0, maxOmissionChars - 1)}…`
|
|
378
|
+
: normalizedOmission || "content truncated";
|
|
379
|
+
const marker = `\n\n[... ${boundedOmission} ...]\n\n`;
|
|
380
|
+
const contentBudget = Math.max(0, MAX_COMPLETION_REPORT_CHARS - marker.length);
|
|
381
|
+
const headBudget = Math.floor(contentBudget * 0.65);
|
|
382
|
+
const tailBudget = Math.max(0, contentBudget - headBudget);
|
|
383
|
+
|
|
384
|
+
const headBreak = text.lastIndexOf("\n", headBudget);
|
|
385
|
+
const head = headBreak >= 0 ? text.slice(0, headBreak) : "";
|
|
386
|
+
const tailWindowStart = Math.max(0, text.length - tailBudget);
|
|
387
|
+
const tailBreak = text.indexOf("\n", tailWindowStart);
|
|
388
|
+
const tail = tailBreak >= 0 ? text.slice(tailBreak + 1) : "";
|
|
389
|
+
return `${head}${marker}${tail}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const resultActionHint = (id: string): string => `intercom { action:"result", to:"${id}" }`;
|
|
393
|
+
|
|
394
|
+
/** A timeout is a status update, not an invitation to poll. The passive completion notifier is
|
|
395
|
+
* the normal delivery path, so send the supervisor back to useful work and reserve peek/steer/stop
|
|
396
|
+
* for intervention. */
|
|
397
|
+
export function buildWaitTimeoutNote(ids: readonly string[], timeoutMs: number): string {
|
|
398
|
+
const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
399
|
+
const omitted = ids.length - visibleIds.length;
|
|
400
|
+
const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
|
|
401
|
+
return (
|
|
402
|
+
`⏳ still running after ${timeoutMs}ms: ${idSummary}. ` +
|
|
403
|
+
"Continue useful supervisor work; completion will notify you automatically. " +
|
|
404
|
+
"Use peek only when status is needed, steer to redirect, or stop a truly stalled run — do not immediately wait again."
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The explicit overflow signal for the one case retention cannot cover: more runs settled than the
|
|
410
|
+
* tracker keeps, so some of the ids this very report names can no longer be fetched. Saying it here,
|
|
411
|
+
* where the results are still in front of the supervisor, is what keeps "each result returns to you"
|
|
412
|
+
* honest — the alternative is a bare "no such run" later, when the payload is unrecoverable.
|
|
413
|
+
*/
|
|
414
|
+
export function buildRetentionOverflowNote(droppedIds: readonly string[], retention: number): string {
|
|
415
|
+
if (droppedIds.length === 0) return "";
|
|
416
|
+
const visible = droppedIds.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
417
|
+
const omitted = droppedIds.length - visible.length;
|
|
418
|
+
const idSummary = `${visible.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
|
|
419
|
+
return (
|
|
420
|
+
`⚠ ${droppedIds.length} of these run${droppedIds.length === 1 ? "" : "s"} can no longer be fetched by id (${idSummary}): ` +
|
|
421
|
+
`more runs settled than the ${retention}-run retention bound keeps, so their full payloads were dropped. ` +
|
|
422
|
+
"Work from the summaries above. To keep more, raise PI_PERSONA_ASYNC_RETAIN or fan out in narrower waves and collect as you go."
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Return the complete text for one run for an explicit/on-demand result action. */
|
|
427
|
+
export function getFullRunOutput(run: AsyncRun): string {
|
|
428
|
+
if (run.result?.output?.trim()) return run.result.output;
|
|
429
|
+
if (run.progress.output.trim()) return run.progress.output;
|
|
430
|
+
if (run.error?.trim()) return run.error;
|
|
431
|
+
return "(no output)";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function clipRunOutput(text: string, id: string, maxChars: number, label = "result output"): string {
|
|
435
|
+
const budget = Math.max(0, Math.floor(maxChars));
|
|
436
|
+
if (text.length <= budget) return text;
|
|
437
|
+
const marker = `\n\n[... ${label} truncated; ${text.length - budget} characters omitted for ${id}. Full output: ${resultActionHint(id)} ...]\n\n`;
|
|
438
|
+
// A hostile/oversized id can make the drill-down marker longer than the entire
|
|
439
|
+
// budget. Keep the hard cap absolute; callers may still retrieve the full output
|
|
440
|
+
// through the internal run id, while this notification surface stays bounded.
|
|
441
|
+
if (marker.length >= budget) return marker.slice(0, budget);
|
|
442
|
+
const contentBudget = budget - marker.length;
|
|
443
|
+
const headChars = Math.ceil(contentBudget / 2);
|
|
444
|
+
const tailChars = Math.max(0, contentBudget - headChars);
|
|
445
|
+
return `${text.slice(0, headChars)}${marker}${tailChars > 0 ? text.slice(-tailChars) : ""}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function buildPeekDigest(runs: AsyncRun[], opts?: { now?: number; stallMs?: number }): string {
|
|
449
|
+
if (runs.length === 0) return "No async runs.";
|
|
450
|
+
const running = runs.filter((r) => r.status === "running").length;
|
|
451
|
+
const now = opts?.now;
|
|
452
|
+
const stallMs = opts?.stallMs;
|
|
453
|
+
const visibleRuns = runs.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
454
|
+
const lines = visibleRuns.map((r) => {
|
|
455
|
+
// Canonical display name: the codename the launcher gave it (the SAME name the agent-tree
|
|
456
|
+
// node shows) + its short model, e.g. "atlas-static · sonnet" — never the bare agent TYPE,
|
|
457
|
+
// which reads as a different sub-agent than the one the tree shows. Falls back to `agent`
|
|
458
|
+
// for call sites that never carried a label (keeps this back-compat).
|
|
459
|
+
const name = runDisplayName(r);
|
|
460
|
+
const head = `[${r.id}] ${name} — ${r.status}`;
|
|
461
|
+
if (r.status === "running") {
|
|
462
|
+
let line = `${head} (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
463
|
+
if (now !== undefined && stallMs !== undefined && stallMs > 0 && r.lastAdvanceAt !== undefined) {
|
|
464
|
+
const stalledFor = now - r.lastAdvanceAt;
|
|
465
|
+
if (stalledFor >= stallMs) line += ` ⚠ possibly stuck (no progress for ${Math.round(stalledFor / 1000)}s)`;
|
|
466
|
+
}
|
|
467
|
+
return line;
|
|
468
|
+
}
|
|
469
|
+
// Peek is a ProgressView that can be returned directly as a tool result or injected into a
|
|
470
|
+
// routine follow-up. Never put child-authored output or error text on this path: even a short
|
|
471
|
+
// preview is an untrusted prompt-injection carrier. The explicit result action is the fenced,
|
|
472
|
+
// on-demand surface for details.
|
|
473
|
+
const took = durationSuffix(r, "in");
|
|
474
|
+
if (r.status === "failed") return `${head}${took}: failure details available via intercom result (to: ${r.id})`;
|
|
475
|
+
if (r.result) return `${head}${took}: full result available via intercom result (to: ${r.id})`;
|
|
476
|
+
return `${head}${took}`;
|
|
477
|
+
});
|
|
478
|
+
const omitted = runs.length - visibleRuns.length;
|
|
479
|
+
if (omitted > 0) lines.push(`… ${omitted} additional async runs omitted from this bounded status view.`);
|
|
480
|
+
if (running > 0) lines.push(PROGRESS_TOKEN_NOTE);
|
|
481
|
+
return [`Async runs: ${runs.length} (${running} running)`, ...lines].join("\n");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Turns the periodic peek from a poll into an EXCEPTION signal. Tracks which running legs have already
|
|
486
|
+
* been surfaced as stalled, so the supervisor is woken ONCE when a leg crosses the stall window — never
|
|
487
|
+
* every tick while it stays stalled, and never at all while it is making progress. A leg that advances
|
|
488
|
+
* again (its `lastAdvanceAt` moves) re-arms, so a later stall re-alerts; legs that leave the list are
|
|
489
|
+
* forgotten. Pure w.r.t. the injected `now`, so it is unit-tested without a real clock.
|
|
490
|
+
*/
|
|
491
|
+
export class PeekWatcher {
|
|
492
|
+
/** runId → the `lastAdvanceAt` value at which we reported it stalled (a later advance re-arms). */
|
|
493
|
+
private readonly reportedAt = new Map<string, number>();
|
|
494
|
+
|
|
495
|
+
/** The running legs that have NEWLY crossed the stall window since we last reported them. */
|
|
496
|
+
poll(runs: AsyncRun[], now: number, stallMs: number): AsyncRun[] {
|
|
497
|
+
const live = new Set<string>();
|
|
498
|
+
const newlyStuck: AsyncRun[] = [];
|
|
499
|
+
for (const r of runs) {
|
|
500
|
+
if (r.status !== "running" || r.lastAdvanceAt === undefined) continue;
|
|
501
|
+
live.add(r.id);
|
|
502
|
+
const prev = this.reportedAt.get(r.id);
|
|
503
|
+
if (prev !== undefined && r.lastAdvanceAt > prev) this.reportedAt.delete(r.id); // advanced ⇒ re-arm
|
|
504
|
+
if (stallMs > 0 && now - r.lastAdvanceAt >= stallMs && !this.reportedAt.has(r.id)) {
|
|
505
|
+
this.reportedAt.set(r.id, r.lastAdvanceAt);
|
|
506
|
+
newlyStuck.push(r);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const id of [...this.reportedAt.keys()]) if (!live.has(id)) this.reportedAt.delete(id);
|
|
510
|
+
return newlyStuck;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Forget all tracked state (session start / reload hygiene). */
|
|
514
|
+
reset(): void {
|
|
515
|
+
this.reportedAt.clear();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The periodic peek's wake message for a batch of NEWLY-stalled legs (from {@link PeekWatcher}), or ""
|
|
521
|
+
* when none. Unlike {@link buildPeekDigest} (the full, on-demand `/peek` view), this is an exception
|
|
522
|
+
* signal, not a status dump: a long operation looks identical to a stall from outside, so the framing is
|
|
523
|
+
* patience-first, and intervention means asking the leg — never reaching into its environment to check.
|
|
524
|
+
*/
|
|
525
|
+
export function buildPeekAlert(stuck: AsyncRun[], opts: { now: number }): string {
|
|
526
|
+
if (stuck.length === 0) return "";
|
|
527
|
+
const visibleStuck = stuck.slice(0, MAX_ASYNC_STATUS_ROWS);
|
|
528
|
+
const lines = visibleStuck.map((r) => {
|
|
529
|
+
const secs = Math.round((opts.now - (r.lastAdvanceAt ?? opts.now)) / 1000);
|
|
530
|
+
return `⚠ ${r.id} (${runDisplayName(r)}) — no visible progress for ${secs}s (${r.progress.turns} turns, ${compactTokens(r.progress.tokens)} cumulative input + output tokens)`;
|
|
531
|
+
});
|
|
532
|
+
const omitted = stuck.length - visibleStuck.length;
|
|
533
|
+
if (omitted > 0) lines.push(`… ${omitted} additional stalled-run alerts omitted from this bounded view.`);
|
|
534
|
+
lines.push(PROGRESS_TOKEN_NOTE);
|
|
535
|
+
return [
|
|
536
|
+
`${stuck.length} background ${stuck.length === 1 ? "leg" : "legs"} may be stalled:`,
|
|
537
|
+
...lines,
|
|
538
|
+
"A long operation (a slow scan, a big generation, a blocking command) looks the same as a stall from out here. " +
|
|
539
|
+
"If it's likely still working, leave it — the result returns to you as a follow-up. If you think it's genuinely " +
|
|
540
|
+
"wedged, ask the leg for a one-line status (a light `steer`) or `stop` it — don't run commands in its environment to check.",
|
|
541
|
+
].join("\n");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The routine check-in: the full progress view ({@link buildPeekDigest}) plus a one-line framing that
|
|
546
|
+
* this is an occasional glance, not a poll — step in only if a leg is off-track or wedged, otherwise
|
|
547
|
+
* carry on. It fires on the slow check-in cadence (config.checkInEveryMs) and is the counterpart to the
|
|
548
|
+
* fast {@link buildPeekAlert} stall signal: it catches a leg going the WRONG way (not stalled, just
|
|
549
|
+
* wrong) before it burns the budget, without waking the supervisor every tick.
|
|
550
|
+
*/
|
|
551
|
+
export function buildCheckIn(runs: AsyncRun[], opts: { now: number; stallMs: number }): string {
|
|
552
|
+
return (
|
|
553
|
+
`${buildPeekDigest(runs, opts)}\n\n` +
|
|
554
|
+
"Routine check-in — internal status update, not a new task: don't reply just to acknowledge unchanged progress. Continue your current " +
|
|
555
|
+
"work and step in only if a leg is off-track (wrong target, rabbit hole) or wedged. Each result returns to you on its own."
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Build ONE consolidated supervisor notice for a batch of settled async runs. Coalescing a
|
|
561
|
+
* burst into a single message is what keeps completions from piling up as separate queued
|
|
562
|
+
* follow-ups (pi renders one sticky line per queued message); the explicit guidance keeps the
|
|
563
|
+
* supervisor from blindly re-issuing a delegation that just failed. `fence` wraps untrusted
|
|
564
|
+
* sub-agent text so the supervisor treats it as data, never as instructions.
|
|
565
|
+
*/
|
|
566
|
+
export function buildCompletionReport(runs: AsyncRun[], fence: (text: string) => string): string {
|
|
567
|
+
const done = runs.filter((r) => r.status === "done");
|
|
568
|
+
const failed = runs.filter((r) => r.status === "failed");
|
|
569
|
+
const stopped = runs.filter((r) => r.status === "stopped");
|
|
570
|
+
// Share the notification budget across every leg that can contribute payload. This keeps a
|
|
571
|
+
// large fan-out fair: the first result cannot consume the whole follow-up while later statuses
|
|
572
|
+
// are still waiting to be rendered. The final report cap remains a hard backstop for metadata.
|
|
573
|
+
const payloadRuns = runs.filter((r) => r.status === "done" || r.progress.output.trim());
|
|
574
|
+
const fairRunChars = Math.min(
|
|
575
|
+
MAX_COMPLETION_RUN_CHARS,
|
|
576
|
+
Math.max(256, Math.floor(MAX_COMPLETION_REPORT_CHARS / Math.max(1, payloadRuns.length * 2))),
|
|
577
|
+
);
|
|
578
|
+
// Failures are always reported — the supervisor must know so it can adjust or tell
|
|
579
|
+
// the user. Blind retry loops are prevented at RUNTIME by the DelegationLedger
|
|
580
|
+
// (an identical delegation that failed twice is vetoed before it spawns), not by
|
|
581
|
+
// hiding information.
|
|
582
|
+
// First line stays short and informative — pi's queued-message UI shows only this line, truncated.
|
|
583
|
+
const stoppedCount = stopped.length > 0 ? `, ${stopped.length} stopped` : "";
|
|
584
|
+
const head = `[pi-persona] ${runs.length} async run${runs.length === 1 ? "" : "s"} settled — ${done.length} done, ${failed.length} failed${stoppedCount}`;
|
|
585
|
+
const blocks: string[] = [head];
|
|
586
|
+
for (const r of done) {
|
|
587
|
+
// Only fence REAL output — an empty/whitespace result gets a plain "(no output)", never an
|
|
588
|
+
// empty <fence></fence> shell (there's nothing untrusted to guard, and the empty block reads
|
|
589
|
+
// as clutter).
|
|
590
|
+
const full = getFullRunOutput(r);
|
|
591
|
+
const body = full !== "(no output)" ? fence(clipRunOutput(full, r.id, fairRunChars)) : "(no output)";
|
|
592
|
+
blocks.push(`\n✅ ${r.id} (${runDisplayName(r)}) done${durationSuffix(r, "in")}:\n${body}`);
|
|
593
|
+
}
|
|
594
|
+
if (stopped.length > 0) {
|
|
595
|
+
const reasons = stopped.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "stopped by supervisor"}`).join("\n");
|
|
596
|
+
blocks.push(`\n⏹ ${stopped.length} stopped:\n${fence(reasons)}`);
|
|
597
|
+
// A deliberate stop is not a failure and must not trigger retry guidance. Its accumulated
|
|
598
|
+
// output is still valuable, so preserve the same bounded, fenced salvage guarantee.
|
|
599
|
+
for (const r of stopped) {
|
|
600
|
+
const partial = r.progress.output.trim();
|
|
601
|
+
if (partial) {
|
|
602
|
+
blocks.push(`\n↩ ${r.id} (${runDisplayName(r)}) partial output before it was stopped:\n${fence(clipRunOutput(partial, r.id, fairRunChars, "partial output"))}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (failed.length > 0) {
|
|
607
|
+
const reasons = failed.map((r) => `• ${r.id} (${runDisplayName(r)})${durationSuffix(r, "after")}: ${r.error ?? "(no detail)"}`).join("\n");
|
|
608
|
+
blocks.push(
|
|
609
|
+
`\n❌ ${failed.length} failed:\n${fence(clipRunOutput(reasons, "failures", fairRunChars))}\n\n` +
|
|
610
|
+
"Handle each failure deliberately: retry ONCE with a different model or approach, or report it to the user. " +
|
|
611
|
+
"If a failed/aborted leg left partial output below, salvage what's usable instead of re-running from scratch. " +
|
|
612
|
+
"Do not re-issue the same failing delegation repeatedly.",
|
|
613
|
+
);
|
|
614
|
+
// Salvage: a failed leg's partial output (its last progress snapshot) is NOT lost — surface it
|
|
615
|
+
// fenced so real work can be reused rather than thrown away. Only when there's something to show.
|
|
616
|
+
for (const r of failed) {
|
|
617
|
+
const partial = r.progress.output.trim();
|
|
618
|
+
if (partial) {
|
|
619
|
+
blocks.push(`\n↩ ${r.id} (${runDisplayName(r)}) partial output before it failed:\n${fence(clipRunOutput(partial, r.id, fairRunChars, "partial output"))}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return boundCompletionSurface(blocks.join("\n"));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* The completion report PLUS the premature-surrender counterweight. When a settled DONE leg's report
|
|
628
|
+
* carries a surrender marker, `scan` returns the note to append (else undefined). BOTH the background
|
|
629
|
+
* completion notifier and the `intercom wait` join render through this, so a blocked leg gets the
|
|
630
|
+
* same counterweight however it is collected — the sync `tool_result` hook never sees a background
|
|
631
|
+
* run. Only DONE legs are scanned: a FAILED leg is already surfaced as a failure by
|
|
632
|
+
* {@link buildCompletionReport}, so the guard and the append agree (no done-vs-all mismatch). `scan`
|
|
633
|
+
* is injected (PersistenceNudge.scan) to keep this module decoupled from the nudge.
|
|
634
|
+
*/
|
|
635
|
+
export function renderCompletion(
|
|
636
|
+
runs: AsyncRun[],
|
|
637
|
+
fence: (text: string) => string,
|
|
638
|
+
scan: (text: string) => string | undefined,
|
|
639
|
+
): string {
|
|
640
|
+
const report = buildCompletionReport(runs, fence);
|
|
641
|
+
const doneOutput = runs
|
|
642
|
+
.filter((r) => r.status === "done")
|
|
643
|
+
.map((r) => getFullRunOutput(r))
|
|
644
|
+
.join("\n");
|
|
645
|
+
const note = scan(doneOutput);
|
|
646
|
+
return boundCompletionSurface(note ? `${report}\n\n${note}` : report);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** How many consecutive empty renders of the SAME batch are retried before it is released. A
|
|
650
|
+
* renderer returning "" for a non-empty batch is a bug, so the batch is put back — but the retry
|
|
651
|
+
* timer is deliberately ref'd, so an unbounded requeue would spin (and hold the host process open)
|
|
652
|
+
* for the rest of the session. */
|
|
653
|
+
export const MAX_EMPTY_RENDER_RETRIES = 3;
|
|
654
|
+
|
|
655
|
+
export interface IdleNotifierDeps<T> {
|
|
656
|
+
/** Whether the supervisor is idle (not streaming a turn). */
|
|
657
|
+
isIdle: () => boolean;
|
|
658
|
+
/** Legacy host-queue probe. Retained for source compatibility, but deliberately ignored: when
|
|
659
|
+
* the supervisor is idle, starting a turn is what drains any orphaned host follow-ups. */
|
|
660
|
+
hasPending?: () => boolean;
|
|
661
|
+
/** Deliver the coalesced message. Async rejection is treated exactly like a synchronous throw:
|
|
662
|
+
* the batch is restored at the front of the queue and retried. */
|
|
663
|
+
deliver: (message: string) => void | Promise<void>;
|
|
664
|
+
/** Render a batch of buffered items into one message. */
|
|
665
|
+
render: (items: T[]) => string;
|
|
666
|
+
/** Schedule a callback; returns a handle. Injected so the clock is controllable in tests. */
|
|
667
|
+
setTimer: (fn: () => void, ms: number) => unknown;
|
|
668
|
+
/** Cancel a scheduled callback. */
|
|
669
|
+
clearTimer: (handle: unknown) => void;
|
|
670
|
+
/** Coalesce window for a burst of items (default 150ms). */
|
|
671
|
+
debounceMs?: number;
|
|
672
|
+
/** Re-poll cadence while the supervisor is busy (default 400ms). */
|
|
673
|
+
retryMs?: number;
|
|
674
|
+
/** Floor (ms) between successive deliveries — a flush due sooner re-arms for the remainder instead
|
|
675
|
+
* of delivering, WITHOUT dropping what's buffered. Unset ⇒ no floor. */
|
|
676
|
+
minIntervalMs?: number;
|
|
677
|
+
/** Once this many deliveries have gone out, further flushes retain the buffer until
|
|
678
|
+
* {@link IdleCoalescingNotifier.resetDeliveries} reopens the gate. Unset ⇒ no ceiling. */
|
|
679
|
+
maxDeliveries?: number;
|
|
680
|
+
/** Maximum queued items rendered into one delivery. Remaining items stay ordered in the queue
|
|
681
|
+
* and are scheduled for a later flush; unset preserves the normal whole-burst coalescing.
|
|
682
|
+
* A function receives the queue (in order) and returns how many LEADING items this delivery may
|
|
683
|
+
* carry — for a channel whose per-delivery budget is measured in content, not in items, so the
|
|
684
|
+
* drain keeps pace with small items instead of being pinned to the worst case. Either form is
|
|
685
|
+
* clamped to at least one item: a queue must always drain. */
|
|
686
|
+
maxBatchItems?: number | ((pending: readonly T[]) => number);
|
|
687
|
+
/** Clock hook, injected for deterministic tests (defaults to `Date.now`). */
|
|
688
|
+
now?: () => number;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Coalesces items into a single supervisor message, delivered only while the supervisor is idle.
|
|
693
|
+
* A stale host follow-up is NOT a blocker: an idle delivery starts the clean turn that can drain it.
|
|
694
|
+
* Self-healing: while busy it re-arms; sync throws and async rejections restore the whole batch.
|
|
695
|
+
* Used for async-run completions and child intercom asks.
|
|
696
|
+
*/
|
|
697
|
+
export class IdleCoalescingNotifier<T> {
|
|
698
|
+
private readonly deps: IdleNotifierDeps<T>;
|
|
699
|
+
private readonly pending: T[] = [];
|
|
700
|
+
private handle: unknown;
|
|
701
|
+
private readonly debounceMs: number;
|
|
702
|
+
private readonly retryMs: number;
|
|
703
|
+
private readonly minIntervalMs: number | undefined;
|
|
704
|
+
private readonly maxDeliveries: number | undefined;
|
|
705
|
+
private readonly maxBatchItems: ((pending: readonly T[]) => number) | undefined;
|
|
706
|
+
private readonly now: () => number;
|
|
707
|
+
private lastDeliveredAt = 0;
|
|
708
|
+
private deliveries = 0;
|
|
709
|
+
/** Consecutive empty renders since the last successful one — the {@link MAX_EMPTY_RENDER_RETRIES}
|
|
710
|
+
* budget. Reset by any render that produces text, so a transient bug costs nothing later. */
|
|
711
|
+
private emptyRenders = 0;
|
|
712
|
+
/** One awaitable delivery at a time. Void deliveries stay on the synchronous fast path. */
|
|
713
|
+
private flushing: Promise<void> | undefined;
|
|
714
|
+
/** The batch currently handed to an async host delivery. Explicit collection can remove an item
|
|
715
|
+
* from a later retry, even after the batch has left `pending` (the synchronous Pi delivery path
|
|
716
|
+
* has no interleaving window, but test/RPC hosts may return a promise). */
|
|
717
|
+
private activeBatch: T[] | undefined;
|
|
718
|
+
/** Invalidates an in-flight batch when cancel() tears down/reuses this notifier. */
|
|
719
|
+
private generation = 0;
|
|
720
|
+
|
|
721
|
+
constructor(deps: IdleNotifierDeps<T>) {
|
|
722
|
+
this.deps = deps;
|
|
723
|
+
this.debounceMs = deps.debounceMs ?? 150;
|
|
724
|
+
this.retryMs = deps.retryMs ?? 400;
|
|
725
|
+
this.minIntervalMs = deps.minIntervalMs;
|
|
726
|
+
this.maxDeliveries = deps.maxDeliveries;
|
|
727
|
+
const batchLimit = deps.maxBatchItems;
|
|
728
|
+
this.maxBatchItems = typeof batchLimit === "function"
|
|
729
|
+
? batchLimit
|
|
730
|
+
: batchLimit !== undefined && Number.isFinite(batchLimit)
|
|
731
|
+
? ((): number => Math.max(1, Math.floor(batchLimit)))
|
|
732
|
+
: undefined;
|
|
733
|
+
this.now = deps.now ?? Date.now;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Buffer an item and arm a coalesced flush. */
|
|
737
|
+
notify(item: T): void {
|
|
738
|
+
this.pending.push(item);
|
|
739
|
+
this.arm(this.debounceMs);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Cancel any armed flush AND drop buffered items (reload hygiene — never leak a timer or a
|
|
743
|
+
* previous session's undelivered items across sessions; the instance may be reused). */
|
|
744
|
+
cancel(): void {
|
|
745
|
+
this.generation += 1;
|
|
746
|
+
this.clearArmedTimer();
|
|
747
|
+
this.pending.length = 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** A snapshot of the buffered-but-not-yet-delivered items (e.g. runs that SETTLED but whose
|
|
751
|
+
* follow-up hasn't flushed yet). Lets `intercom wait`/`peek` surface results in the
|
|
752
|
+
* settle→deliver gap instead of reporting nothing; the caller `discard`s what it collects so
|
|
753
|
+
* the pending flush won't re-deliver it. */
|
|
754
|
+
peekPending(): T[] {
|
|
755
|
+
return [...this.pending];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** Drop buffered items matching `pred` — e.g. results the supervisor already collected
|
|
759
|
+
* synchronously (intercom `wait`), which must not be re-delivered as a follow-up. */
|
|
760
|
+
discard(pred: (item: T) => boolean): void {
|
|
761
|
+
for (let i = this.pending.length - 1; i >= 0; i--) {
|
|
762
|
+
if (pred(this.pending[i] as T)) this.pending.splice(i, 1);
|
|
763
|
+
}
|
|
764
|
+
if (this.activeBatch) {
|
|
765
|
+
for (let i = this.activeBatch.length - 1; i >= 0; i--) {
|
|
766
|
+
if (pred(this.activeBatch[i] as T)) this.activeBatch.splice(i, 1);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
if (this.pending.length === 0) this.clearArmedTimer();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Clear the `maxDeliveries` ceiling and immediately retry anything held behind it. */
|
|
773
|
+
resetDeliveries(): void {
|
|
774
|
+
this.deliveries = 0;
|
|
775
|
+
if (this.pending.length > 0) this.kick();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Public idle-transition hook. Safe to call repeatedly (e.g. from `agent_settled`): async
|
|
779
|
+
* deliveries share one flight; synchronous deliveries complete before this method returns. */
|
|
780
|
+
flushIfIdle(): Promise<void> {
|
|
781
|
+
if (this.flushing) return this.flushing;
|
|
782
|
+
this.clearArmedTimer();
|
|
783
|
+
if (this.pending.length === 0) return Promise.resolve();
|
|
784
|
+
|
|
785
|
+
const generation = this.generation;
|
|
786
|
+
let batch: T[] | undefined;
|
|
787
|
+
try {
|
|
788
|
+
// Do NOT gate an idle supervisor on its host queue. A stale follow-up can survive an
|
|
789
|
+
// aborted/errored turn; starting this clean turn is what lets the host drain it.
|
|
790
|
+
if (!this.deps.isIdle()) {
|
|
791
|
+
this.arm(this.retryMs);
|
|
792
|
+
return Promise.resolve();
|
|
793
|
+
}
|
|
794
|
+
// A delivery ceiling is a rate limit, never a drop policy. Keep the buffer alive until
|
|
795
|
+
// resetDeliveries() reopens the gate (the retry is also a liveness fallback).
|
|
796
|
+
if (this.maxDeliveries !== undefined && this.deliveries >= this.maxDeliveries) {
|
|
797
|
+
this.arm(this.retryMs);
|
|
798
|
+
return Promise.resolve();
|
|
799
|
+
}
|
|
800
|
+
if (this.minIntervalMs !== undefined && this.deliveries > 0) {
|
|
801
|
+
const elapsed = this.now() - this.lastDeliveredAt;
|
|
802
|
+
if (elapsed < this.minIntervalMs) {
|
|
803
|
+
this.arm(this.minIntervalMs - elapsed);
|
|
804
|
+
return Promise.resolve();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const take = this.maxBatchItems === undefined ? this.pending.length : Math.max(1, Math.floor(this.maxBatchItems(this.pending)));
|
|
809
|
+
batch = this.pending.splice(0, take);
|
|
810
|
+
const message = this.deps.render(batch);
|
|
811
|
+
if (!message) {
|
|
812
|
+
// Empty text from a non-empty batch is a renderer failure, not an acknowledgement: put the
|
|
813
|
+
// batch back at the front so a TRANSIENT formatter bug cannot silently discard comms. A
|
|
814
|
+
// persistently empty renderer is a different problem — retry a bounded number of times,
|
|
815
|
+
// then let the batch go rather than re-arm forever against a bug no retry will fix.
|
|
816
|
+
this.emptyRenders += 1;
|
|
817
|
+
if (this.emptyRenders <= MAX_EMPTY_RENDER_RETRIES) {
|
|
818
|
+
this.pending.unshift(...batch);
|
|
819
|
+
} else {
|
|
820
|
+
this.emptyRenders = 0;
|
|
821
|
+
if (process.env.PI_PERSONA_DEBUG) {
|
|
822
|
+
process.stderr.write(`[pi-persona] notifier released ${batch.length} item(s): renderer returned empty text ${MAX_EMPTY_RENDER_RETRIES + 1}x\n`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
this.arm(this.retryMs);
|
|
826
|
+
return Promise.resolve();
|
|
827
|
+
}
|
|
828
|
+
this.emptyRenders = 0;
|
|
829
|
+
this.activeBatch = batch;
|
|
830
|
+
const delivery = this.deps.deliver(message);
|
|
831
|
+
if (!delivery || typeof (delivery as PromiseLike<void>).then !== "function") {
|
|
832
|
+
this.activeBatch = undefined;
|
|
833
|
+
if (generation === this.generation) {
|
|
834
|
+
this.lastDeliveredAt = this.now();
|
|
835
|
+
this.deliveries += 1;
|
|
836
|
+
if (this.pending.length > 0) this.arm(this.retryMs);
|
|
837
|
+
}
|
|
838
|
+
return Promise.resolve();
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
let flight!: Promise<void>;
|
|
842
|
+
flight = Promise.resolve(delivery)
|
|
843
|
+
.then(() => {
|
|
844
|
+
if (generation !== this.generation) return;
|
|
845
|
+
this.lastDeliveredAt = this.now();
|
|
846
|
+
this.deliveries += 1;
|
|
847
|
+
})
|
|
848
|
+
.catch(() => {
|
|
849
|
+
const retryBatch = this.activeBatch === batch ? this.activeBatch : batch;
|
|
850
|
+
this.activeBatch = undefined;
|
|
851
|
+
if (generation !== this.generation) return;
|
|
852
|
+
this.pending.unshift(...(retryBatch as T[]));
|
|
853
|
+
this.arm(this.retryMs);
|
|
854
|
+
})
|
|
855
|
+
.finally(() => {
|
|
856
|
+
if (this.activeBatch === batch) this.activeBatch = undefined;
|
|
857
|
+
if (this.flushing === flight) this.flushing = undefined;
|
|
858
|
+
if (this.pending.length > 0 && this.handle === undefined) this.arm(this.retryMs);
|
|
859
|
+
});
|
|
860
|
+
this.flushing = flight;
|
|
861
|
+
return flight;
|
|
862
|
+
} catch {
|
|
863
|
+
const retryBatch = this.activeBatch === batch ? this.activeBatch : batch;
|
|
864
|
+
this.activeBatch = undefined;
|
|
865
|
+
if (generation === this.generation) {
|
|
866
|
+
if (retryBatch) this.pending.unshift(...retryBatch);
|
|
867
|
+
this.arm(this.retryMs);
|
|
868
|
+
}
|
|
869
|
+
return Promise.resolve();
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/** Fire-and-forget alias for event hooks and timer callbacks. */
|
|
874
|
+
kick(): void {
|
|
875
|
+
void this.flushIfIdle();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private clearArmedTimer(): void {
|
|
879
|
+
if (this.handle === undefined) return;
|
|
880
|
+
this.deps.clearTimer(this.handle);
|
|
881
|
+
this.handle = undefined;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private arm(ms: number): void {
|
|
885
|
+
if (this.handle !== undefined || this.pending.length === 0) return;
|
|
886
|
+
let scheduled!: unknown;
|
|
887
|
+
scheduled = this.deps.setTimer(() => {
|
|
888
|
+
if (this.handle !== scheduled) return; // cancelled/replaced timer racing its callback
|
|
889
|
+
this.handle = undefined;
|
|
890
|
+
this.kick();
|
|
891
|
+
}, ms);
|
|
892
|
+
this.handle = scheduled;
|
|
893
|
+
// Delivery is an outstanding obligation. Re-ref a host timer that the wiring created
|
|
894
|
+
// unref'd; cancel() remains the explicit lifecycle escape hatch.
|
|
895
|
+
const ref = (scheduled as { ref?: () => void } | null | undefined)?.ref;
|
|
896
|
+
if (typeof ref === "function") ref.call(scheduled);
|
|
897
|
+
}
|
|
898
|
+
}
|