pi-crew 0.11.1 → 0.11.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/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -49,6 +49,13 @@ export interface ObservabilityState {
|
|
|
49
49
|
autoRepairTimer: ReturnType<typeof setInterval> | undefined;
|
|
50
50
|
tempReconcileTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
51
|
otlpExporter: OTLPExporterInstance | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* F11 (RR-018): promise of the most recent `configureObservability` run.
|
|
54
|
+
* `disposeObservability` settles it deliberately so teardown never races a
|
|
55
|
+
* still-suspended init continuation (which re-checks ownership after every
|
|
56
|
+
* await and self-disposes its locals when ownership was lost).
|
|
57
|
+
*/
|
|
58
|
+
initPromise: Promise<void> | undefined;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/** Dependencies passed in by register.ts so this module stays decoupled. */
|
|
@@ -57,7 +64,18 @@ export interface ObservabilityDeps {
|
|
|
57
64
|
getManifestCache: (cwd: string) => ReturnType<typeof import("../../runtime/manifest-cache.ts").createManifestCache>;
|
|
58
65
|
notifyOperator: (notification: NotificationDescriptor) => void;
|
|
59
66
|
isCleanedUp: () => boolean;
|
|
60
|
-
|
|
67
|
+
/**
|
|
68
|
+
* F11 (RR-018): read the RegistrationContext's `sessionGeneration`. The
|
|
69
|
+
* generation is bumped by cleanup AND by every session_start/before_switch,
|
|
70
|
+
* so unlike `isCleanedUp` it can never be reset back to "still owner" by a
|
|
71
|
+
* newer session. Reuse this counter — do not invent a parallel one.
|
|
72
|
+
*/
|
|
73
|
+
getSessionGeneration: () => number;
|
|
74
|
+
reconcileStaleRuns: (
|
|
75
|
+
cwd: string,
|
|
76
|
+
cache: ReturnType<ObservabilityDeps["getManifestCache"]>,
|
|
77
|
+
currentSessionId?: string,
|
|
78
|
+
) => unknown[] | Promise<unknown[] | undefined>;
|
|
61
79
|
reconcileOrphanedTempWorkspaces: (now: number, opts: { cleanupOrphanedTempDirs?: boolean }) => unknown;
|
|
62
80
|
cleanupOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
|
|
63
81
|
cleanupLegacyOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
|
|
@@ -94,43 +112,77 @@ async function importOTLPExporter(): Promise<OTLPExporterCtor> {
|
|
|
94
112
|
* - `config.reliability?.autoRecover === true` → lazy-imports crash-recovery
|
|
95
113
|
* on a deferred setTimeout to avoid blocking session_start.
|
|
96
114
|
*/
|
|
97
|
-
export
|
|
115
|
+
export function configureObservability(ctx: ExtensionContext, state: ObservabilityState, deps: ObservabilityDeps): Promise<void> {
|
|
116
|
+
// F11: track the init promise so `disposeObservability` can settle it
|
|
117
|
+
// deliberately instead of racing it. The internal run first awaits any
|
|
118
|
+
// PREVIOUS init (via disposeObservability), so this assignment can never
|
|
119
|
+
// self-deadlock — state.initPromise still referenced the old run when the
|
|
120
|
+
// internal run suspended.
|
|
121
|
+
const run = configureObservabilityInternal(ctx, state, deps);
|
|
122
|
+
state.initPromise = run;
|
|
123
|
+
return run;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function configureObservabilityInternal(ctx: ExtensionContext, state: ObservabilityState, deps: ObservabilityDeps): Promise<void> {
|
|
127
|
+
// F11 (RR-018): capture the session generation at configure time. Every
|
|
128
|
+
// await boundary below re-verifies ownership before touching shared state;
|
|
129
|
+
// a continuation whose generation no longer matches can never publish —
|
|
130
|
+
// even when a newer session has already reset `cleanedUp` back to false
|
|
131
|
+
// (the real ordering: cleanup sets it true, session_start resets it false).
|
|
132
|
+
const ownerGeneration = deps.getSessionGeneration();
|
|
133
|
+
const stillOwns = (): boolean => deps.getSessionGeneration() === ownerGeneration && !deps.isCleanedUp();
|
|
134
|
+
|
|
98
135
|
// Always start from a clean slate: dispose any prior-session state first.
|
|
99
136
|
await disposeObservability(state, deps.isCleanedUp());
|
|
137
|
+
if (!stillOwns()) return;
|
|
100
138
|
|
|
101
139
|
const config = loadConfig(ctx.cwd).config;
|
|
102
140
|
if (config.observability?.enabled === false) return;
|
|
103
141
|
|
|
104
142
|
// LAZY: observability stack — only paid for when observability is actually enabled
|
|
105
143
|
const { createMetricRegistry } = await import("../../observability/metric-registry.ts");
|
|
144
|
+
if (!stillOwns()) return;
|
|
106
145
|
// LAZY: event→metric bridge
|
|
107
146
|
const { wireEventToMetrics } = await import("../../observability/event-to-metric.ts");
|
|
147
|
+
if (!stillOwns()) return;
|
|
108
148
|
// LAZY: file-backed metric sink
|
|
109
149
|
const { createMetricFileSink } = await import("../../observability/metric-sink.ts");
|
|
150
|
+
if (!stillOwns()) return;
|
|
110
151
|
|
|
111
|
-
state
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
152
|
+
// F11: create into LOCALS — shared state is only touched after ownership is
|
|
153
|
+
// re-verified. If ownership was lost in the gap, everything just created is
|
|
154
|
+
// disposed right here in the continuation instead of leaking (0 orphans).
|
|
155
|
+
const metricRegistry = createMetricRegistry();
|
|
156
|
+
const eventMetricSub = deps.pi.events ? wireEventToMetrics(deps.pi.events, metricRegistry) : undefined;
|
|
157
|
+
const metricSink =
|
|
158
|
+
config.telemetry?.enabled !== false
|
|
159
|
+
? createMetricFileSink({
|
|
160
|
+
crewRoot: projectCrewRoot(ctx.cwd),
|
|
161
|
+
registry: metricRegistry,
|
|
162
|
+
retentionDays: config.observability?.metricRetentionDays ?? 7,
|
|
163
|
+
})
|
|
164
|
+
: undefined;
|
|
165
|
+
if (!stillOwns()) {
|
|
166
|
+
eventMetricSub?.dispose();
|
|
167
|
+
metricSink?.dispose();
|
|
168
|
+
metricRegistry.dispose();
|
|
169
|
+
return;
|
|
121
170
|
}
|
|
171
|
+
state.metricRegistry = metricRegistry;
|
|
172
|
+
state.eventMetricSub = eventMetricSub;
|
|
173
|
+
state.metricSink = metricSink;
|
|
122
174
|
|
|
123
175
|
// OTLP export is opt-in. Lazy-loaded via dynamic import.
|
|
124
176
|
if (config.otlp?.enabled === true && config.otlp.endpoint) {
|
|
125
177
|
const otlpEndpoint = config.otlp.endpoint;
|
|
126
178
|
const otlpHeaders = config.otlp.headers;
|
|
127
179
|
const otlpInterval = config.otlp.intervalMs;
|
|
128
|
-
const owningRegistry =
|
|
180
|
+
const owningRegistry = metricRegistry;
|
|
129
181
|
// LAZY: opt-in OTLP export — load the exporter module on first enable.
|
|
130
182
|
void importOTLPExporter()
|
|
131
183
|
.then((Ctor) => {
|
|
132
|
-
if (
|
|
133
|
-
|
|
184
|
+
if (!stillOwns() || state.metricRegistry !== owningRegistry || !owningRegistry) return;
|
|
185
|
+
const otlpExporter = new Ctor(
|
|
134
186
|
{
|
|
135
187
|
endpoint: otlpEndpoint,
|
|
136
188
|
headers: otlpHeaders,
|
|
@@ -138,18 +190,20 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
|
|
|
138
190
|
},
|
|
139
191
|
owningRegistry,
|
|
140
192
|
);
|
|
141
|
-
state.otlpExporter
|
|
193
|
+
state.otlpExporter = otlpExporter;
|
|
194
|
+
otlpExporter.start();
|
|
142
195
|
})
|
|
143
196
|
.catch((error: unknown) => logInternalError("register.otlp-lazy-import", error));
|
|
144
197
|
}
|
|
145
198
|
|
|
146
199
|
// LAZY: heartbeat watcher — polled per-session, wires deadletter + metric events.
|
|
147
200
|
const { HeartbeatWatcher } = await import("../../runtime/heartbeat/heartbeat-watcher.ts");
|
|
148
|
-
|
|
201
|
+
if (!stillOwns()) return;
|
|
202
|
+
const heartbeatWatcher = new HeartbeatWatcher({
|
|
149
203
|
cwd: ctx.cwd,
|
|
150
204
|
pollIntervalMs: config.observability?.pollIntervalMs ?? 5000,
|
|
151
205
|
manifestCache: deps.getManifestCache(ctx.cwd),
|
|
152
|
-
registry:
|
|
206
|
+
registry: metricRegistry,
|
|
153
207
|
router: {
|
|
154
208
|
enqueue: (notification) => {
|
|
155
209
|
deps.notifyOperator(notification);
|
|
@@ -173,25 +227,24 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
|
|
|
173
227
|
});
|
|
174
228
|
},
|
|
175
229
|
});
|
|
176
|
-
|
|
230
|
+
// F11: the watcher is created into a local and only published once
|
|
231
|
+
// ownership is re-verified — a lost-ownership continuation disposes it
|
|
232
|
+
// instead of publishing a live poller nobody will tear down.
|
|
233
|
+
if (!stillOwns()) {
|
|
234
|
+
heartbeatWatcher.dispose();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
state.heartbeatWatcher = heartbeatWatcher;
|
|
238
|
+
heartbeatWatcher.start();
|
|
177
239
|
|
|
178
240
|
// RT-F2: opportunistic stale-run reconcile hook.
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
try {
|
|
187
|
-
deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
|
|
188
|
-
} catch (error) {
|
|
189
|
-
logInternalError("register.autoRepair.turnHook", error);
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
} catch {
|
|
193
|
-
/* older Pi without before_agent_start — rely on the interval alone */
|
|
194
|
-
}
|
|
241
|
+
// F12 (RR-018): the `before_agent_start` hook is NO LONGER registered here.
|
|
242
|
+
// It is registered exactly ONCE per extension in `lazy-configurers.ts`
|
|
243
|
+
// (installTurnReconcileHook) and resolves the CURRENT session context at
|
|
244
|
+
// fire time — per-session registration accumulated one dead hook per
|
|
245
|
+
// switch (there is no pi.off), and the stale hook re-activated with the
|
|
246
|
+
// old session's cwd, flipping the shared manifest cache back. The
|
|
247
|
+
// .unref()'d setInterval below remains the idle-time safety net.
|
|
195
248
|
|
|
196
249
|
// Auto-repair timers: stale-run reconcile + orphan-temp cleanup.
|
|
197
250
|
// RT-F2: default raised from 60_000ms to 5 minutes (300_000ms). The previous
|
|
@@ -204,26 +257,31 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
|
|
|
204
257
|
if (autoRepairIntervalMs > 0) {
|
|
205
258
|
state.autoRepairTimer = setInterval(() => {
|
|
206
259
|
if (deps.isCleanedUp()) return;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
260
|
+
// RR-021 WI-1.5: reconcileStaleRuns may be async (mapConcurrent) —
|
|
261
|
+
// settle the result before inspecting, keep errors inside the log.
|
|
262
|
+
void (async () => {
|
|
263
|
+
try {
|
|
264
|
+
const settled = await deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
|
|
265
|
+
const staleResults = Array.isArray(settled) ? settled : [];
|
|
266
|
+
if (staleResults.length > 0) {
|
|
267
|
+
for (const result of staleResults) {
|
|
268
|
+
const repaired = (result as { repaired?: boolean }).repaired;
|
|
269
|
+
if (repaired) {
|
|
270
|
+
deps.notifyOperator({
|
|
271
|
+
id: `auto_repair_${(result as { runId: string }).runId}`,
|
|
272
|
+
severity: "info",
|
|
273
|
+
source: "auto-repair",
|
|
274
|
+
runId: (result as { runId: string }).runId,
|
|
275
|
+
title: `Auto-repaired stale run`,
|
|
276
|
+
body: (result as { detail?: string }).detail ?? "",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
221
279
|
}
|
|
222
280
|
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logInternalError("register.autoRepair", error);
|
|
223
283
|
}
|
|
224
|
-
}
|
|
225
|
-
logInternalError("register.autoRepair", error);
|
|
226
|
-
}
|
|
284
|
+
})();
|
|
227
285
|
}, autoRepairIntervalMs);
|
|
228
286
|
state.autoRepairTimer.unref();
|
|
229
287
|
|
|
@@ -273,7 +331,7 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
|
|
|
273
331
|
void deps
|
|
274
332
|
.importCrashRecovery()
|
|
275
333
|
.then(({ detectInterruptedRuns }) => {
|
|
276
|
-
if (
|
|
334
|
+
if (!stillOwns()) return;
|
|
277
335
|
const sid = extractSessionId(ctx);
|
|
278
336
|
for (const plan of detectInterruptedRuns(cwdSnapshot, cacheSnapshot, 300_000, sid)) {
|
|
279
337
|
deps.notifyOperator({
|
|
@@ -296,6 +354,20 @@ export async function configureObservability(ctx: ExtensionContext, state: Obser
|
|
|
296
354
|
* gated by the orchestrator's overall cleanup state.
|
|
297
355
|
*/
|
|
298
356
|
export async function disposeObservability(state: ObservabilityState, _isCleanedUp: boolean): Promise<void> {
|
|
357
|
+
// F11 (RR-018): settle any in-flight init BEFORE disposing shared state, so
|
|
358
|
+
// teardown never races a still-suspended continuation. The continuation
|
|
359
|
+
// itself re-checks ownership after every await and self-disposes its
|
|
360
|
+
// locals, so this await only bounds the race window — it never starts new
|
|
361
|
+
// work and cannot deadlock (the previous init completes on its own).
|
|
362
|
+
const initPromise = state.initPromise;
|
|
363
|
+
state.initPromise = undefined;
|
|
364
|
+
if (initPromise) {
|
|
365
|
+
try {
|
|
366
|
+
await initPromise;
|
|
367
|
+
} catch {
|
|
368
|
+
/* configureObservabilityImpl logs its own errors */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
299
371
|
state.heartbeatWatcher?.dispose();
|
|
300
372
|
state.heartbeatWatcher = undefined;
|
|
301
373
|
if (state.autoRepairTimer) {
|
|
@@ -37,12 +37,14 @@ export type AbortControllerMap = Map<AbortKey, AbortController>;
|
|
|
37
37
|
/**
|
|
38
38
|
* Single auto-recovery cooldown gate entry. Tracked by the render-tick
|
|
39
39
|
* health notifier so the same warning doesn't fire repeatedly within a
|
|
40
|
-
* 5-minute cooldown
|
|
40
|
+
* 5-minute cooldown, and (FINDING 6) at most MAX_HEALTH_NOTIFY_FIRES times
|
|
41
|
+
* per unchanged fingerprint. LRU-like semantics — see register.ts and
|
|
42
|
+
* health-notify-policy.ts (the authoritative definition; re-exported here
|
|
43
|
+
* so existing imports keep working).
|
|
41
44
|
*/
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
45
|
+
export type { AutoRecoveryEntry } from "./health-notify-policy.ts";
|
|
46
|
+
|
|
47
|
+
import type { AutoRecoveryEntry } from "./health-notify-policy.ts";
|
|
46
48
|
|
|
47
49
|
/**
|
|
48
50
|
* Mutable context bag passed from the orchestrator to every
|
|
@@ -93,7 +93,14 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
|
|
|
93
93
|
void disposeObservability(ctx.observabilityState, ctx.cleanedUp);
|
|
94
94
|
ctx.lifecycleState.deliveryCoordinator?.dispose();
|
|
95
95
|
clearHooksScoped();
|
|
96
|
-
|
|
96
|
+
// F13 (RR-018): RPC + the crew global registry are EXTENSION-lifetime
|
|
97
|
+
// resources — they are NOT torn down on a session switch. The RPC
|
|
98
|
+
// handle resolves the current session context at request time
|
|
99
|
+
// (wire-cross-extension.ts), and session_start rebinds the registry to
|
|
100
|
+
// the current session's manifest cache (refreshCrossExtensionWiringForSession).
|
|
101
|
+
// Removing them here used to kill RPC permanently for the process
|
|
102
|
+
// lifetime (4 pi-crew:rpc:* subscriptions → 0 after the first switch,
|
|
103
|
+
// never reinstalled). Full teardown still happens in cleanupRuntime.
|
|
97
104
|
ctx.lifecycleState.overflowTracker?.dispose();
|
|
98
105
|
ctx.lifecycleState.deliveryCoordinator = undefined;
|
|
99
106
|
ctx.lifecycleState.overflowTracker = undefined;
|
|
@@ -104,8 +111,7 @@ function buildCleanupSessionResourcesOnly(ctx: RegistrationContext): () => void
|
|
|
104
111
|
ctx.renderScheduler = undefined;
|
|
105
112
|
ctx.autoRecoveryLast.clear();
|
|
106
113
|
disposeNotifications(ctx.lifecycleState);
|
|
107
|
-
ctx.rpcHandle
|
|
108
|
-
ctx.rpcHandle = undefined;
|
|
114
|
+
// F13: ctx.rpcHandle stays installed here (extension lifetime).
|
|
109
115
|
ctx.disposeI18n();
|
|
110
116
|
ctx.sessionGeneration += 1;
|
|
111
117
|
ctx.currentCtx = undefined;
|
|
@@ -123,3 +123,41 @@ export function __test__subagentSpawnParams(params: Record<string, unknown>, ctx
|
|
|
123
123
|
batchId: typeof params.batch_id === "string" && params.batch_id.trim() ? params.batch_id.trim() : undefined,
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* FINDING 6 (2026-09-23 battery): when a notification is cleared (e.g. the
|
|
129
|
+
* health monitor resolved a false/stale alert), copies of the ORIGINAL
|
|
130
|
+
* warning may still sit in the host session's follow-up queue — the host
|
|
131
|
+
* drains queued follow-ups one per turn boundary, so stale copies kept
|
|
132
|
+
* dripping into the parent conversation for hours after the clear.
|
|
133
|
+
*
|
|
134
|
+
* Feature-detected purge: newer pi runtimes expose
|
|
135
|
+
* `clearQueuedUserMessagesMatching(predicate)` (agent-session), which removes
|
|
136
|
+
* still-QUEUED messages whose text matches. Duck-typed here because the
|
|
137
|
+
* installed runtime surface (0.87.0) does not export it yet — on such hosts
|
|
138
|
+
* this is a documented no-op (returns false); the fire-cap policy in
|
|
139
|
+
* health-notify-policy.ts is the defense that works on all hosts.
|
|
140
|
+
*/
|
|
141
|
+
interface QueuePurgeCapablePi {
|
|
142
|
+
clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
|
|
143
|
+
session?: {
|
|
144
|
+
clearQueuedUserMessagesMatching?: (predicate: (text: string) => boolean) => { steering: string[]; followUp: string[] };
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function purgeQueuedAmbientNotifications(pi: ExtensionAPI, match: (text: string) => boolean): boolean {
|
|
149
|
+
const api = pi as unknown as QueuePurgeCapablePi;
|
|
150
|
+
const surface =
|
|
151
|
+
typeof api.clearQueuedUserMessagesMatching === "function"
|
|
152
|
+
? api.clearQueuedUserMessagesMatching
|
|
153
|
+
: typeof api.session?.clearQueuedUserMessagesMatching === "function"
|
|
154
|
+
? api.session.clearQueuedUserMessagesMatching
|
|
155
|
+
: undefined;
|
|
156
|
+
if (!surface) return false;
|
|
157
|
+
try {
|
|
158
|
+
const removed = surface.call(api.session ?? api, match);
|
|
159
|
+
return (removed?.followUp?.length ?? 0) + (removed?.steering?.length ?? 0) > 0;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { TObject } from "@sinclair/typebox";
|
|
5
|
-
import {
|
|
5
|
+
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
6
6
|
import { loadConfig } from "../../config/config.ts";
|
|
7
7
|
import { findClosestKey } from "../../config/suggestions.ts";
|
|
8
8
|
import type { MetricRegistry } from "../../observability/metric-registry.ts";
|
|
@@ -36,6 +36,12 @@ import { withSessionId } from "../team-tool/context.ts";
|
|
|
36
36
|
import { formatTeamToolParamError } from "../team-tool/param-error.ts";
|
|
37
37
|
import { toolResult } from "../tool-result.ts";
|
|
38
38
|
|
|
39
|
+
// RR-021 WI-4.3g: compile the team-tool schema ONCE. Value.Check re-walks the
|
|
40
|
+
// whole (large) schema object on every tool call; TypeCompiler.Compile (STATIC
|
|
41
|
+
// method on @sinclair/typebox/compiler) builds a specialized checker at module
|
|
42
|
+
// load. The error formatter keeps using the schema + Value.Errors (cold path).
|
|
43
|
+
const compiledTeamToolParams = TypeCompiler.Compile(TeamToolParams);
|
|
44
|
+
|
|
39
45
|
/**
|
|
40
46
|
* Normalize model-generated tool params before schema validation.
|
|
41
47
|
*
|
|
@@ -228,7 +234,8 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
|
|
|
228
234
|
// validation so strict unions/patterns accept "unset" as omitted.
|
|
229
235
|
const normalized = normalizeTeamParams(params);
|
|
230
236
|
// Defense-in-depth: validate params at runtime even though Pi framework already does
|
|
231
|
-
|
|
237
|
+
// (RR-021 WI-4.3g: precompiled checker — same semantics as Value.Check.)
|
|
238
|
+
if (!compiledTeamToolParams.Check(normalized)) {
|
|
232
239
|
return toolResult(formatTeamToolParamError(TeamToolParams, normalized), { action: "list", status: "error" }, true);
|
|
233
240
|
}
|
|
234
241
|
// EXT-1: additionalProperties:true lets unknown (typo'd) keys slip past
|
|
@@ -28,6 +28,7 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
|
|
|
28
28
|
// in the entire runtime chain) out of the cold-start module graph. EXT-9:
|
|
29
29
|
// the registry now lives in module-scoped state, not globalThis.
|
|
30
30
|
void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
|
|
31
|
+
// LAZY: defer team-tool until cross-extension wiring fires
|
|
31
32
|
const manifestCacheForRegistry = ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd());
|
|
32
33
|
installCrewGlobalRegistry({
|
|
33
34
|
manifestCache: manifestCacheForRegistry,
|
|
@@ -35,3 +36,32 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
|
|
|
35
36
|
});
|
|
36
37
|
});
|
|
37
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* F13 (RR-018): refresh the extension-lifetime cross-extension wiring for a
|
|
42
|
+
* new session. Called on every `session_start`.
|
|
43
|
+
*
|
|
44
|
+
* The RPC handle is installed ONCE at registration and survives session
|
|
45
|
+
* switches — its `getCtx` closure resolves the CURRENT session context at
|
|
46
|
+
* request time, so nothing needs reinstalling (re-registering would double
|
|
47
|
+
* the pi-crew:rpc:* subscriptions). The crew global registry IS rebound to
|
|
48
|
+
* the current session's manifest cache so a cross-project switch never
|
|
49
|
+
* leaves it reading a stale project's runs. Idempotent and cheap (a plain
|
|
50
|
+
* object construction — installCrewGlobalRegistry swaps the module-scoped
|
|
51
|
+
* singleton atomically).
|
|
52
|
+
*/
|
|
53
|
+
export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
54
|
+
if (!ctx.rpcHandle) {
|
|
55
|
+
// Edge: registration never installed the handle (or a full shutdown
|
|
56
|
+
// removed it) — install it once, idempotently.
|
|
57
|
+
installCrossExtensionWiring(pi, ctx);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
|
|
61
|
+
// LAZY: defer team-tool until cross-extension wiring fires
|
|
62
|
+
installCrewGlobalRegistry({
|
|
63
|
+
manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
|
|
64
|
+
cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readEvents } from "../state/event-log/event-log.ts";
|
|
2
|
+
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
3
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
4
|
+
import { formatCost, formatTokens } from "../state/usage.ts";
|
|
5
|
+
import { formatDuration } from "../ui/format-helpers.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* US-021 (2026-09-22) — run comparison (before/after diff of two runs).
|
|
9
|
+
*
|
|
10
|
+
* Scope per spec docs/specs/US-021.md: status delta, per-task
|
|
11
|
+
* status/duration/tokens/cost deltas, event-type set diff, model routing diff.
|
|
12
|
+
* NOT in scope: visual UI, git integration.
|
|
13
|
+
*
|
|
14
|
+
* Determinism: the markdown body contains NO wall-clock (the spec pins this so
|
|
15
|
+
* comparisons are byte-diffable — same pattern as US-022's injectable `now`).
|
|
16
|
+
* Everything rendered derives from run data; the artifact's own write time is
|
|
17
|
+
* filesystem metadata, not content. The output also deliberately contains NO
|
|
18
|
+
* absolute paths (ids, statuses and deltas only), so the run-export
|
|
19
|
+
* `redactHomePaths` pass is unnecessary here — noted because the spec asked.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface TaskDelta {
|
|
23
|
+
id: string;
|
|
24
|
+
statusA?: string;
|
|
25
|
+
statusB?: string;
|
|
26
|
+
/** b − a; undefined when either side lacks the data (task missing, or no timestamps). */
|
|
27
|
+
durationDeltaMs?: number;
|
|
28
|
+
/** b − a; undefined when either side lacks usage. */
|
|
29
|
+
tokensDelta?: number;
|
|
30
|
+
/** b − a; undefined when either side lacks cost. */
|
|
31
|
+
costDelta?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ModelRoutingDiff {
|
|
35
|
+
taskId: string;
|
|
36
|
+
modelA?: string;
|
|
37
|
+
modelB?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RunComparison {
|
|
41
|
+
runA: string;
|
|
42
|
+
runB: string;
|
|
43
|
+
status: { a: string; b: string };
|
|
44
|
+
/** True when every compared field matches (the self-diff invariant). */
|
|
45
|
+
identical: boolean;
|
|
46
|
+
/** One row per task id present in EITHER run. */
|
|
47
|
+
tasks: TaskDelta[];
|
|
48
|
+
tasksOnlyInA: string[];
|
|
49
|
+
tasksOnlyInB: string[];
|
|
50
|
+
events: { addedTypes: string[]; removedTypes: string[] };
|
|
51
|
+
models: ModelRoutingDiff[];
|
|
52
|
+
usage: { tokensDelta: number; costDelta: number; tokensA: number; tokensB: number; costA: number; costB: number };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function taskDurationMs(task: TeamTaskState): number | undefined {
|
|
56
|
+
if (!task.startedAt || !task.finishedAt) return undefined;
|
|
57
|
+
const ms = new Date(task.finishedAt).getTime() - new Date(task.startedAt).getTime();
|
|
58
|
+
return Number.isFinite(ms) ? ms : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function taskTokens(task: TeamTaskState): number | undefined {
|
|
62
|
+
if (!task.usage) return undefined;
|
|
63
|
+
const { input, output } = task.usage;
|
|
64
|
+
if (input === undefined && output === undefined) return undefined;
|
|
65
|
+
return (input ?? 0) + (output ?? 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function taskCost(task: TeamTaskState): number | undefined {
|
|
69
|
+
const cost = task.usage?.cost;
|
|
70
|
+
return typeof cost === "number" && Number.isFinite(cost) ? cost : undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sumTokens(tasks: TeamTaskState[]): number {
|
|
74
|
+
return tasks.reduce((acc, task) => acc + (taskTokens(task) ?? 0), 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sumCost(tasks: TeamTaskState[]): number {
|
|
78
|
+
return tasks.reduce((acc, task) => acc + (taskCost(task) ?? 0), 0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function eventTypeSet(eventsPath: string): Set<string> {
|
|
82
|
+
const types = new Set<string>();
|
|
83
|
+
for (const event of readEvents(eventsPath)) {
|
|
84
|
+
if (event.type) types.add(event.type);
|
|
85
|
+
}
|
|
86
|
+
return types;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function compareRuns(
|
|
90
|
+
manifestA: TeamRunManifest,
|
|
91
|
+
tasksA: TeamTaskState[],
|
|
92
|
+
manifestB: TeamRunManifest,
|
|
93
|
+
tasksB: TeamTaskState[],
|
|
94
|
+
): RunComparison {
|
|
95
|
+
const byIdA = new Map(tasksA.map((task) => [task.id, task] as const));
|
|
96
|
+
const byIdB = new Map(tasksB.map((task) => [task.id, task] as const));
|
|
97
|
+
const allIds = [...new Set([...byIdA.keys(), ...byIdB.keys()])].sort();
|
|
98
|
+
|
|
99
|
+
const tasks: TaskDelta[] = allIds.map((id) => {
|
|
100
|
+
const a = byIdA.get(id);
|
|
101
|
+
const b = byIdB.get(id);
|
|
102
|
+
const delta: TaskDelta = { id };
|
|
103
|
+
if (a) delta.statusA = a.status;
|
|
104
|
+
if (b) delta.statusB = b.status;
|
|
105
|
+
const durA = a ? taskDurationMs(a) : undefined;
|
|
106
|
+
const durB = b ? taskDurationMs(b) : undefined;
|
|
107
|
+
if (durA !== undefined && durB !== undefined) delta.durationDeltaMs = durB - durA;
|
|
108
|
+
const tokA = a ? taskTokens(a) : undefined;
|
|
109
|
+
const tokB = b ? taskTokens(b) : undefined;
|
|
110
|
+
if (tokA !== undefined && tokB !== undefined) delta.tokensDelta = tokB - tokA;
|
|
111
|
+
const costA = a ? taskCost(a) : undefined;
|
|
112
|
+
const costB = b ? taskCost(b) : undefined;
|
|
113
|
+
if (costA !== undefined && costB !== undefined) delta.costDelta = costB - costA;
|
|
114
|
+
return delta;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const typesA = eventTypeSet(manifestA.eventsPath);
|
|
118
|
+
const typesB = eventTypeSet(manifestB.eventsPath);
|
|
119
|
+
|
|
120
|
+
const models: ModelRoutingDiff[] = allIds
|
|
121
|
+
.filter((id) => {
|
|
122
|
+
const a = byIdA.get(id)?.model;
|
|
123
|
+
const b = byIdB.get(id)?.model;
|
|
124
|
+
return a !== undefined || b !== undefined;
|
|
125
|
+
})
|
|
126
|
+
.map((id) => ({ taskId: id, modelA: byIdA.get(id)?.model, modelB: byIdB.get(id)?.model }))
|
|
127
|
+
.filter((diff) => diff.modelA !== diff.modelB);
|
|
128
|
+
|
|
129
|
+
const statusEqual = manifestA.status === manifestB.status;
|
|
130
|
+
const noTaskDeltas = tasks.every(
|
|
131
|
+
(delta) =>
|
|
132
|
+
delta.statusA === delta.statusB &&
|
|
133
|
+
(delta.durationDeltaMs ?? 0) === 0 &&
|
|
134
|
+
(delta.tokensDelta ?? 0) === 0 &&
|
|
135
|
+
(delta.costDelta ?? 0) === 0,
|
|
136
|
+
);
|
|
137
|
+
const tokensA = sumTokens(tasksA);
|
|
138
|
+
const tokensB = sumTokens(tasksB);
|
|
139
|
+
const costA = sumCost(tasksA);
|
|
140
|
+
const costB = sumCost(tasksB);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
runA: manifestA.runId,
|
|
144
|
+
runB: manifestB.runId,
|
|
145
|
+
status: { a: manifestA.status, b: manifestB.status },
|
|
146
|
+
identical:
|
|
147
|
+
statusEqual && noTaskDeltas && tasksA.length === tasksB.length && tokensA === tokensB && costA === costB && models.length === 0,
|
|
148
|
+
tasks,
|
|
149
|
+
tasksOnlyInA: tasksA.map((t) => t.id).filter((id) => !byIdB.has(id)),
|
|
150
|
+
tasksOnlyInB: tasksB.map((t) => t.id).filter((id) => !byIdA.has(id)),
|
|
151
|
+
events: {
|
|
152
|
+
addedTypes: [...typesB].filter((t) => !typesA.has(t)).sort(),
|
|
153
|
+
removedTypes: [...typesA].filter((t) => !typesB.has(t)).sort(),
|
|
154
|
+
},
|
|
155
|
+
models,
|
|
156
|
+
usage: { tokensDelta: tokensB - tokensA, costDelta: costB - costA, tokensA, tokensB, costA, costB },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function signed(value: number, format: (abs: number) => string): string {
|
|
161
|
+
if (value === 0) return "0";
|
|
162
|
+
return value > 0 ? `+${format(value)}` : `-${format(-value)}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function renderComparisonMarkdown(comparison: RunComparison): string {
|
|
166
|
+
const lines: string[] = [
|
|
167
|
+
`# pi-crew comparison ${comparison.runA} vs ${comparison.runB}`,
|
|
168
|
+
"",
|
|
169
|
+
`Status: a=${comparison.status.a} b=${comparison.status.b}`,
|
|
170
|
+
`Comparison: ${comparison.identical ? "identical" : "different"}`,
|
|
171
|
+
"",
|
|
172
|
+
"## Tasks",
|
|
173
|
+
...(comparison.tasks.length
|
|
174
|
+
? comparison.tasks.map((delta) => {
|
|
175
|
+
const parts = [`- ${delta.id}: a=${delta.statusA ?? "(absent)"} b=${delta.statusB ?? "(absent)"}`];
|
|
176
|
+
if (delta.durationDeltaMs !== undefined) parts.push(`Δdur=${signed(delta.durationDeltaMs, formatDuration)}`);
|
|
177
|
+
if (delta.tokensDelta !== undefined) parts.push(`Δtok=${signed(delta.tokensDelta, formatTokens)}`);
|
|
178
|
+
if (delta.costDelta !== undefined) parts.push(`Δcost=${signed(delta.costDelta, formatCost)}`);
|
|
179
|
+
return parts.join(" ");
|
|
180
|
+
})
|
|
181
|
+
: ["- (none)"]),
|
|
182
|
+
`Only in a: ${comparison.tasksOnlyInA.length ? comparison.tasksOnlyInA.join(", ") : "(none)"}`,
|
|
183
|
+
`Only in b: ${comparison.tasksOnlyInB.length ? comparison.tasksOnlyInB.join(", ") : "(none)"}`,
|
|
184
|
+
"",
|
|
185
|
+
"## Models",
|
|
186
|
+
...(comparison.models.length
|
|
187
|
+
? comparison.models.map((m) => `- ${m.taskId}: ${m.modelA ?? "(none)"} -> ${m.modelB ?? "(none)"}`)
|
|
188
|
+
: ["- (no routing differences)"]),
|
|
189
|
+
"",
|
|
190
|
+
"## Events",
|
|
191
|
+
`Added types: ${comparison.events.addedTypes.length ? comparison.events.addedTypes.join(", ") : "(none)"}`,
|
|
192
|
+
`Removed types: ${comparison.events.removedTypes.length ? comparison.events.removedTypes.join(", ") : "(none)"}`,
|
|
193
|
+
"",
|
|
194
|
+
"## Totals",
|
|
195
|
+
`Tokens: a=${formatTokens(comparison.usage.tokensA)} b=${formatTokens(comparison.usage.tokensB)} Δ=${signed(comparison.usage.tokensDelta, formatTokens)}`,
|
|
196
|
+
`Cost: a=${formatCost(comparison.usage.costA)} b=${formatCost(comparison.usage.costB)} Δ=${signed(comparison.usage.costDelta, formatCost)}`,
|
|
197
|
+
"",
|
|
198
|
+
];
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compare two runs and write the markdown artifact under the FIRST run's
|
|
204
|
+
* artifactsRoot (spec AC-5) at `compare/<runA>__<runB>.md`.
|
|
205
|
+
*/
|
|
206
|
+
export function runCompareBundle(
|
|
207
|
+
manifestA: TeamRunManifest,
|
|
208
|
+
tasksA: TeamTaskState[],
|
|
209
|
+
manifestB: TeamRunManifest,
|
|
210
|
+
tasksB: TeamTaskState[],
|
|
211
|
+
): { comparison: RunComparison; markdownPath: string } {
|
|
212
|
+
const comparison = compareRuns(manifestA, tasksA, manifestB, tasksB);
|
|
213
|
+
const artifact = writeArtifact(manifestA.artifactsRoot, {
|
|
214
|
+
kind: "summary",
|
|
215
|
+
relativePath: `compare/${manifestA.runId}__${manifestB.runId}.md`,
|
|
216
|
+
producer: "run-compare",
|
|
217
|
+
content: renderComparisonMarkdown(comparison),
|
|
218
|
+
});
|
|
219
|
+
return { comparison, markdownPath: artifact.path };
|
|
220
|
+
}
|