pi-crew 0.11.6 → 0.11.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +22 -0
- package/.githooks/pre-push +56 -0
- package/CHANGELOG.md +216 -1
- package/dist/index.mjs +531 -508
- package/docs/publishing.md +29 -0
- package/index.ts +3 -3
- package/package.json +9 -6
- package/scripts/install-hooks.mjs +32 -0
- package/skills/real-test-pi-crew/SKILL.md +31 -18
- package/src/config/config-validation.ts +35 -0
- package/src/config/env-vars.ts +38 -0
- package/src/config/types.ts +71 -0
- package/src/extension/registration/foreground-run-controller.ts +19 -10
- package/src/extension/registration/lifecycle-handlers.ts +6 -491
- package/src/extension/registration/lifecycle.ts +7 -90
- package/src/extension/registration/render-loop.ts +502 -0
- package/src/extension/team-tool/doctor.ts +6 -0
- package/src/prompt/prompt-runtime.ts +33 -4
- package/src/runtime/child-pi/child-pi-spawn.ts +14 -0
- package/src/runtime/child-pi/child-pi.ts +130 -6
- package/src/runtime/child-pi/pi-version.ts +187 -0
- package/src/runtime/child-pi/session-recovery.ts +334 -0
- package/src/runtime/classifier/classifier-service.ts +298 -0
- package/src/runtime/dispatch-batch.ts +4 -0
- package/src/runtime/live-session/live-session-runtime.ts +31 -0
- package/src/runtime/model/model-scope.ts +141 -21
- package/src/runtime/model/pi-args.ts +166 -6
- package/src/runtime/peer-dep.ts +24 -0
- package/src/runtime/process/zombie-scanner.ts +168 -7
- package/src/runtime/rpc/README.md +139 -0
- package/src/runtime/rpc/frame-client.ts +328 -0
- package/src/runtime/rpc/rpc-worker.ts +271 -0
- package/src/runtime/rpc/ui-request-policy.ts +112 -0
- package/src/runtime/run-worker.ts +91 -3
- package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
- package/src/runtime/skill-instructions.ts +66 -1
- package/src/runtime/surface/resolve-surface.ts +34 -19
- package/src/runtime/surface/surface-spawn.ts +8 -1
- package/src/runtime/task-output-context.ts +191 -32
- package/src/runtime/task-packet.ts +3 -0
- package/src/runtime/task-runner/child-executor.ts +172 -2
- package/src/runtime/task-runner/post-execution.ts +5 -3
- package/src/runtime/task-runner/pre-execution.ts +32 -1
- package/src/runtime/task-runner/prompt-builder.ts +10 -2
- package/src/runtime/task-runner.ts +6 -0
- package/src/schema/config-schema.ts +37 -0
- package/src/schema/team-tool-schema.ts +14 -2
- package/src/state/contracts.ts +5 -0
- package/src/ui/format-helpers.ts +11 -0
- package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
- package/src/ui/pi-ui-compat.ts +18 -9
- package/src/ui/rail.ts +14 -1
- package/src/ui/settings-overlay.ts +11 -2
- package/src/ui/widget/index.ts +0 -1
- package/src/ui/widget/task-list.ts +10 -3
- package/src/ui/widget/widget-renderer.ts +53 -4
- package/src/utils/relative-time.ts +11 -3
- package/src/utils/visual.ts +32 -2
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* setupRenderLoop — the per-session render pipeline (RenderScheduler +
|
|
3
|
+
* preload loop + bounded run watchers + health-notification gates) plus the
|
|
4
|
+
* pure frame helpers it shares. Extracted verbatim from
|
|
5
|
+
* lifecycle-handlers.ts (QW#2, deep review 2026-10-01) so the lifecycle
|
|
6
|
+
* module stays focused on session_start/shutdown/before_switch wiring.
|
|
7
|
+
*
|
|
8
|
+
* The loop itself is driven end-to-end through
|
|
9
|
+
* `installSessionLifecycleHandlers` (see preload-idle-render.test.ts); the
|
|
10
|
+
* exported pure helpers are pinned directly by
|
|
11
|
+
* test/unit/extension/registration/lifecycle-health-filter.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { loadConfig } from "../../config/config.ts";
|
|
17
|
+
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
18
|
+
import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } from "../../runtime/detached-run-results.ts";
|
|
19
|
+
import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
|
|
20
|
+
import type { createManifestCache } from "../../runtime/manifest-cache.ts";
|
|
21
|
+
import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
|
|
22
|
+
import type { TeamRunManifest } from "../../state/types.ts";
|
|
23
|
+
import { overlayFreshTaskStatuses, summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
|
|
24
|
+
import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
|
|
25
|
+
import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
|
|
26
|
+
import { RenderScheduler } from "../../ui/render-scheduler.ts";
|
|
27
|
+
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
28
|
+
import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
|
|
29
|
+
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
30
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
31
|
+
import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
|
|
32
|
+
import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
|
|
33
|
+
import { healthNotifyFingerprint, recordHealthNotifyDecision, resetHealthNotifyEntry } from "./health-notify-policy.ts";
|
|
34
|
+
import type { RegistrationContext } from "./registration-types.ts";
|
|
35
|
+
import { purgeQueuedAmbientNotifications } from "./subagent-helpers.ts";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hand over the outcome of runs detached by an agent-view switch.
|
|
39
|
+
*
|
|
40
|
+
* Delivered as a DISPLAYED session entry (not a follow-up queue item): an idle
|
|
41
|
+
* session never flushes a follow-up, so the queued variant left the result
|
|
42
|
+
* invisible. Each result is dropped from the registry only after its send
|
|
43
|
+
* succeeded, and only while the owning session is current — a worker's view
|
|
44
|
+
* session must never receive the parent run's report.
|
|
45
|
+
*/
|
|
46
|
+
export function deliverDetachedRunResults(pi: ExtensionAPI, extensionCtx: ExtensionContext): void {
|
|
47
|
+
if (!hasDetachedRuns()) return;
|
|
48
|
+
try {
|
|
49
|
+
// Agent views are in-document panes now, never sessions: the current
|
|
50
|
+
// session is always the main one, so detached results always deliver.
|
|
51
|
+
const inViewSession = false;
|
|
52
|
+
for (const { runId, text } of peekFinishedDetachedRunResults({ inViewSession })) {
|
|
53
|
+
pi.sendMessage({ customType: "pi-crew-run-result", content: text, display: true });
|
|
54
|
+
forgetDetachedRun(runId);
|
|
55
|
+
try {
|
|
56
|
+
extensionCtx.ui.notify(text.split("\n")[0] ?? `pi-crew run finished: ${runId}`, "info");
|
|
57
|
+
} catch {
|
|
58
|
+
/* toast is secondary to the session entry */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// Keep the entry: the next tick retries rather than losing the outcome.
|
|
63
|
+
logInternalError("register.detachedRunResults", error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Phase 5 (Vector #3): keep only the CURRENT session's owned runs (plus
|
|
69
|
+
* ownerless runs) for health notifications. Previously the inline filter derived
|
|
70
|
+
* `currentSessionId` from a cast that was always `undefined` and compared
|
|
71
|
+
* against `ownerSessionGeneration` (a field absent from TeamRunManifest), so
|
|
72
|
+
* together they dropped EVERY owned run. Exported for unit testing.
|
|
73
|
+
*/
|
|
74
|
+
export function filterManifestsForHealthNotifications(
|
|
75
|
+
manifests: TeamRunManifest[],
|
|
76
|
+
currentSessionId: string | undefined,
|
|
77
|
+
): TeamRunManifest[] {
|
|
78
|
+
return manifests.filter((run) => !run.ownerSessionId || run.ownerSessionId === currentSessionId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* bug-026 sub-issue C: runEventBus event types that mark a run TERMINAL,
|
|
83
|
+
* across BOTH type namespaces seen on the bus — dotted `run.*` strings (kept
|
|
84
|
+
* for parity with classifyEventChannel's WORKER_LIFECYCLE_TYPES, see
|
|
85
|
+
* src/ui/run-event-bus.ts:31-44) and underscore `run_*` (the native
|
|
86
|
+
* RunEventType namespace actually emitted by team-runner, e.g.
|
|
87
|
+
* `run_completed`). Exported for unit testing.
|
|
88
|
+
*/
|
|
89
|
+
export const TERMINAL_RUN_EVENT_TYPES: ReadonlySet<string> = new Set([
|
|
90
|
+
"run.completed",
|
|
91
|
+
"run.failed",
|
|
92
|
+
"run.cancelled",
|
|
93
|
+
"run_completed",
|
|
94
|
+
"run_failed",
|
|
95
|
+
"run_cancelled",
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** True when a runEventBus event type marks the run terminal (either namespace). */
|
|
99
|
+
export function isTerminalRunEventType(type: string): boolean {
|
|
100
|
+
return TERMINAL_RUN_EVENT_TYPES.has(type);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Pure filter: drop `runId` from a preloaded-manifest frame (bug-026 sub-issue C eviction). */
|
|
104
|
+
export function evictRunFromManifests(manifests: TeamRunManifest[], runId: string): TeamRunManifest[] {
|
|
105
|
+
return manifests.filter((m) => m.runId !== runId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* F14 (RR-019): cheap signature of the preloaded-manifest frame. Only a
|
|
110
|
+
* CHANGED signature lets `backgroundPreload` count as render activity
|
|
111
|
+
* (`schedule()`); an identical signature means "nothing the widget would
|
|
112
|
+
* paint differently" and must NOT reset the render scheduler's idle
|
|
113
|
+
* counters (that defeated the R1 idle stop — see setupRenderLoop). Covers
|
|
114
|
+
* run identity, status transitions, and updatedAt bumps. Exported for tests.
|
|
115
|
+
*/
|
|
116
|
+
export function manifestsFrameSignature(manifests: TeamRunManifest[]): string {
|
|
117
|
+
return manifests.map((m) => `${m.runId}:${m.status}:${m.updatedAt}`).join("|");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Apply a runEventBus payload to a preloaded-manifest frame: evict the run on
|
|
122
|
+
* terminal events, pass through unchanged otherwise. This is the exact logic
|
|
123
|
+
* wired into the setupRenderLoop `runEventBus.onAny` subscription (bug-026
|
|
124
|
+
* sub-issue C); exported so tests can exercise it end-to-end against the
|
|
125
|
+
* real bus without spinning up the full render loop.
|
|
126
|
+
*/
|
|
127
|
+
export function applyTerminalRunEventToManifests(manifests: TeamRunManifest[], event: { type: string; runId: string }): TeamRunManifest[] {
|
|
128
|
+
return isTerminalRunEventType(event.type) ? evictRunFromManifests(manifests, event.runId) : manifests;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the render scheduler + preload loop + bounded run watchers.
|
|
133
|
+
*
|
|
134
|
+
* Render path:
|
|
135
|
+
* - RenderScheduler fires renderTick() every `effectiveRefreshMs()`.
|
|
136
|
+
* - 160ms when live agents OR background runs are active (spinner-friendly),
|
|
137
|
+
* else the configured `dashboardLiveRefreshMs` (default DEFAULT_UI.refreshMs).
|
|
138
|
+
* - renderTick reads from a pre-computed frame (`lastPreloadedManifests`) —
|
|
139
|
+
* zero fs I/O on the hot path.
|
|
140
|
+
*
|
|
141
|
+
* Watchers:
|
|
142
|
+
* - pts/2 hang fix (2026-06-16): a SINGLE non-recursive watcher on the
|
|
143
|
+
* `runs/` root (new-run detection) plus per-active-run watchers
|
|
144
|
+
* reconciled each preload tick. Total inotify cost: O(active runs).
|
|
145
|
+
*/
|
|
146
|
+
export function setupRenderLoop(
|
|
147
|
+
pi: ExtensionAPI,
|
|
148
|
+
ctx: RegistrationContext,
|
|
149
|
+
extensionCtx: ExtensionContext,
|
|
150
|
+
loadedConfig: ReturnType<typeof loadConfig>,
|
|
151
|
+
): void {
|
|
152
|
+
ctx.disposeRenderSchedulerSubscriptions();
|
|
153
|
+
ctx.renderScheduler?.dispose();
|
|
154
|
+
ctx.terminalStatus?.dispose();
|
|
155
|
+
ctx.terminalStatus = undefined;
|
|
156
|
+
ctx.terminalStatusActive = false;
|
|
157
|
+
|
|
158
|
+
// Phase 12: Async preloading — renderTick reads only a pre-computed frame.
|
|
159
|
+
let preloading = false;
|
|
160
|
+
let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
|
|
161
|
+
let lastPreloadedManifests: TeamRunManifest[] = [];
|
|
162
|
+
let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
|
|
163
|
+
let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
|
|
164
|
+
// F14: signature of the last frame backgroundPreload saw — only a change
|
|
165
|
+
// here counts as render activity.
|
|
166
|
+
let lastFrameSignature: string | undefined;
|
|
167
|
+
|
|
168
|
+
const ownerGeneration = ctx.sessionGeneration;
|
|
169
|
+
|
|
170
|
+
const buildFrame = async (): Promise<{ ok: boolean; changed: boolean }> => {
|
|
171
|
+
if (!ctx.currentCtx) return { ok: false, changed: false };
|
|
172
|
+
lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
|
|
173
|
+
lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
|
|
174
|
+
lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
|
|
175
|
+
const manifests = lastFrameManifestCache.list(20);
|
|
176
|
+
lastPreloadedManifests = manifests;
|
|
177
|
+
// F14 (RR-019): compute the frame signature BEFORE the preload await so
|
|
178
|
+
// `changed` reflects what this tick saw, not a mid-preload mutation.
|
|
179
|
+
const frameSignature = manifestsFrameSignature(manifests);
|
|
180
|
+
const frameChanged = frameSignature !== lastFrameSignature;
|
|
181
|
+
lastFrameSignature = frameSignature;
|
|
182
|
+
// pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
|
|
183
|
+
{
|
|
184
|
+
const onRunChange = (runId: string): void => {
|
|
185
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
|
|
186
|
+
// FLICKER FIX: rebuild-in-place instead of deleting the entry. The
|
|
187
|
+
// file just changed on disk, so force a fresh snapshot while keeping
|
|
188
|
+
// the entry populated — deleting it left a window where the widget's
|
|
189
|
+
// `get()` returned undefined and dropped the run to "(loading…)".
|
|
190
|
+
// PERF (2026-08-24): route through the coalesced ASYNC refresh —
|
|
191
|
+
// fs.watch can fire many times per second and the sync rebuild
|
|
192
|
+
// blocked the UI event loop. The entry stays populated until the
|
|
193
|
+
// async rebuild re-sets it in place; the render schedule below
|
|
194
|
+
// repaints while it lands.
|
|
195
|
+
try {
|
|
196
|
+
ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
logInternalError("register.runWatcher.refresh", error, runId);
|
|
199
|
+
}
|
|
200
|
+
ctx.renderScheduler?.schedule({ runId });
|
|
201
|
+
};
|
|
202
|
+
const onWatchErr = (error: unknown): void => {
|
|
203
|
+
logInternalError("register.runWatcher.change", error);
|
|
204
|
+
};
|
|
205
|
+
const active = manifests
|
|
206
|
+
.filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
|
|
207
|
+
.map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
|
|
208
|
+
ctx.crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
|
|
209
|
+
ctx.userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
|
|
210
|
+
}
|
|
211
|
+
const runIds = manifests.map((r) => r.runId);
|
|
212
|
+
await lastFrameSnapshotCache.preloadAllStale(runIds);
|
|
213
|
+
return { ok: true, changed: frameChanged };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const backgroundPreload = (): void => {
|
|
217
|
+
if (!ctx.currentCtx || preloading) return;
|
|
218
|
+
preloading = true;
|
|
219
|
+
buildFrame()
|
|
220
|
+
.then(({ ok, changed }) => {
|
|
221
|
+
preloading = false;
|
|
222
|
+
// F14 (RR-019): preload completion is maintenance, NOT user activity.
|
|
223
|
+
// Only a genuinely CHANGED frame may call schedule() — an unchanged
|
|
224
|
+
// frame calling schedule() reset `lastEventAt`/`idleFallbackRenders`
|
|
225
|
+
// and re-armed the fallback loop that fallbackLoop() had just
|
|
226
|
+
// deliberately stopped (R1), rendering forever while idle. Real
|
|
227
|
+
// work still animates: runEventBus + fs.watch subscriptions call
|
|
228
|
+
// schedule() directly on genuine events.
|
|
229
|
+
if (ok && changed) ctx.renderScheduler?.schedule();
|
|
230
|
+
})
|
|
231
|
+
.catch((error: unknown) => {
|
|
232
|
+
preloading = false;
|
|
233
|
+
logInternalError("register.backgroundPreload", error);
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
|
|
238
|
+
if (ctx.preloadTimer) clearTimeout(ctx.preloadTimer);
|
|
239
|
+
const tick = (): void => {
|
|
240
|
+
backgroundPreload();
|
|
241
|
+
const nextMs = dynamicMs?.() ?? intervalMs;
|
|
242
|
+
ctx.preloadTimer = setTimeout(tick, nextMs);
|
|
243
|
+
ctx.preloadTimer.unref();
|
|
244
|
+
};
|
|
245
|
+
ctx.preloadTimer = setTimeout(tick, intervalMs);
|
|
246
|
+
ctx.preloadTimer.unref();
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const renderTick = (): void => {
|
|
250
|
+
if (!ctx.currentCtx) return;
|
|
251
|
+
deliverDetachedRunResults(pi, ctx.currentCtx);
|
|
252
|
+
const config = lastPreloadedConfig?.config.ui;
|
|
253
|
+
const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
|
|
254
|
+
const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
|
|
255
|
+
const manifests = lastPreloadedManifests;
|
|
256
|
+
if (!lastPreloadedConfig) backgroundPreload();
|
|
257
|
+
if (ctx.uiState.liveSidebarRunId || ctx.uiState.dashboardOpen) {
|
|
258
|
+
const placement = toPiWidgetPlacement(config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement);
|
|
259
|
+
if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
|
|
260
|
+
setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
|
|
261
|
+
setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
|
|
262
|
+
ctx.widgetState.lastVisibility = "hidden";
|
|
263
|
+
ctx.widgetState.lastPlacement = placement;
|
|
264
|
+
ctx.widgetState.lastKey = "pi-crew-active";
|
|
265
|
+
ctx.widgetState.model = undefined;
|
|
266
|
+
}
|
|
267
|
+
requestRender(ctx.currentCtx);
|
|
268
|
+
} else {
|
|
269
|
+
updateCrewWidget(ctx.currentCtx, ctx.widgetState, config, activeCache, snapshotCache, manifests);
|
|
270
|
+
}
|
|
271
|
+
requestPowerbarUpdate(
|
|
272
|
+
pi.events,
|
|
273
|
+
ctx.currentCtx.cwd,
|
|
274
|
+
config,
|
|
275
|
+
activeCache,
|
|
276
|
+
snapshotCache,
|
|
277
|
+
ctx.currentCtx,
|
|
278
|
+
ctx.widgetState.notificationCount ?? 0,
|
|
279
|
+
manifests,
|
|
280
|
+
);
|
|
281
|
+
// Health notifications: only warn about genuinely running runs.
|
|
282
|
+
// Phase 5 (Vector #3): derive currentSessionId via the working accessor.
|
|
283
|
+
// ctx is RegistrationContext; currentCtx holds the ExtensionContext whose
|
|
284
|
+
// sessionManager exposes getSessionId(). The previous cast to {sessionId?}
|
|
285
|
+
// was always undefined, and the ownerSessionGeneration clause referenced a
|
|
286
|
+
// field absent from TeamRunManifest — together they dropped EVERY owned
|
|
287
|
+
// run. Now only the current session's owned runs + ownerless runs pass.
|
|
288
|
+
const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
|
|
289
|
+
const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
|
|
290
|
+
const now = Date.now();
|
|
291
|
+
// FIX #2: clear path — when a run is detected terminal, dismiss any
|
|
292
|
+
// previously-emitted health notification for it AND drop its cooldown
|
|
293
|
+
// (autoRecoveryLast) so a future genuine re-occurrence can re-notify.
|
|
294
|
+
// Keeps the dashboard clean and stops the 5-min re-fire cycle.
|
|
295
|
+
const clearHealthNotifications = (runId: string): void => {
|
|
296
|
+
for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
|
|
297
|
+
const key = `${kind}_${runId}`;
|
|
298
|
+
// FINDING 6: reset the fire budget (a genuine recurrence after
|
|
299
|
+
// this clear re-notifies) AND opportunistically purge
|
|
300
|
+
// still-QUEUED host follow-up copies of the original warning —
|
|
301
|
+
// the host drains the queue one message per turn boundary, so
|
|
302
|
+
// stale copies otherwise drip in for hours after the clear
|
|
303
|
+
// (live: 5h of "missing heartbeat" replays after the 2026-09-23
|
|
304
|
+
// zombie cleanup). The purge is feature-detected: hosts without
|
|
305
|
+
// clearQueuedUserMessagesMatching (e.g. 0.87.0) no-op here; the
|
|
306
|
+
// fire-cap policy bounds the backlog on every host.
|
|
307
|
+
resetHealthNotifyEntry({ entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES }, key);
|
|
308
|
+
ctx.notifyOperator({
|
|
309
|
+
id: key,
|
|
310
|
+
clear: true,
|
|
311
|
+
severity: "info",
|
|
312
|
+
source: "health",
|
|
313
|
+
runId,
|
|
314
|
+
title: `Cleared ${kind} for ${runId}`,
|
|
315
|
+
});
|
|
316
|
+
purgeQueuedAmbientNotifications(
|
|
317
|
+
pi,
|
|
318
|
+
(text) => text.includes(runId) && (text.includes("dead worker") || text.includes("missing heartbeat")),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
for (const run of sessionManifests) {
|
|
323
|
+
if (run.status !== "running") {
|
|
324
|
+
// GATE 1 — preloaded manifest says terminal. Purge any stale snapshot
|
|
325
|
+
// and clear previously-emitted health notifications so the dashboard
|
|
326
|
+
// stays clean (belt-and-suspenders with the FIX #1 fresh-read gate).
|
|
327
|
+
snapshotCache.invalidate(run.runId);
|
|
328
|
+
clearHealthNotifications(run.runId);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
// FIX #1: re-verify against a FRESH manifest read. The preloaded `run`
|
|
333
|
+
// (from lastPreloadedManifests) can lag the on-disk terminal
|
|
334
|
+
// transition; the manifest cache has a 500ms TTL + file watcher so it
|
|
335
|
+
// is the source of truth. A terminal run must NEVER reach
|
|
336
|
+
// maybeNotifyHealth. Also purge the stale snapshot + clear any
|
|
337
|
+
// previously-emitted health notification for this run.
|
|
338
|
+
const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
|
|
339
|
+
if (freshManifest?.status !== "running") {
|
|
340
|
+
snapshotCache.invalidate(run.runId);
|
|
341
|
+
clearHealthNotifications(run.runId);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const snapshot = snapshotCache.get(run.runId);
|
|
345
|
+
if (!snapshot) continue;
|
|
346
|
+
if (snapshot.manifest.status !== "running") {
|
|
347
|
+
// GATE 2 — a running snapshot paired with a now-terminal manifest is
|
|
348
|
+
// stale. Purge it so subsequent ticks get a fresh view, and clear.
|
|
349
|
+
snapshotCache.invalidate(run.runId);
|
|
350
|
+
clearHealthNotifications(run.runId);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// GATE 3 (FINDING 5) — task-status truth lives on DISK, not in the
|
|
354
|
+
// snapshot cache. A worker parked on `ask` writes running → waiting
|
|
355
|
+
// to tasks.json immediately, but the cached snapshot can lag that
|
|
356
|
+
// transition; in the window a parked (alive, silent-by-design)
|
|
357
|
+
// worker counted as active-without-heartbeat and fired a false
|
|
358
|
+
// "dead worker" (live: team_20260923100114, 01_explore parked on
|
|
359
|
+
// ask). Overlay FRESH statuses before summarizing; divergence also
|
|
360
|
+
// invalidates the stale cache entry so the next tick rebuilds it.
|
|
361
|
+
const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
|
|
362
|
+
const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
|
|
363
|
+
if (overlaid !== snapshot) snapshotCache.invalidate(run.runId);
|
|
364
|
+
const summary = summarizeHeartbeats(overlaid, { now });
|
|
365
|
+
const fingerprint = healthNotifyFingerprint(summary, overlaid.tasks.length);
|
|
366
|
+
const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
|
|
367
|
+
if (count <= 0) return;
|
|
368
|
+
const key = `${kind}_${run.runId}`;
|
|
369
|
+
// FINDING 6: bounded re-fire — ≤ MAX_HEALTH_NOTIFY_FIRES per
|
|
370
|
+
// UNCHANGED fingerprint (5-min cooldown between fires). The
|
|
371
|
+
// old forever-re-arming cooldown fed the host follow-up queue
|
|
372
|
+
// duplicates that drained one-per-turn-boundary for hours.
|
|
373
|
+
// Policy detail + LRU eviction: health-notify-policy.ts.
|
|
374
|
+
const state = { entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES };
|
|
375
|
+
if (!recordHealthNotifyDecision(state, key, fingerprint, now)) return;
|
|
376
|
+
ctx.notifyOperator({
|
|
377
|
+
id: key,
|
|
378
|
+
severity: "warning",
|
|
379
|
+
source: "health",
|
|
380
|
+
runId: run.runId,
|
|
381
|
+
title,
|
|
382
|
+
body,
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
maybeNotifyHealth(
|
|
386
|
+
"recovery_dead_workers",
|
|
387
|
+
summary.dead,
|
|
388
|
+
`Run ${run.runId} has ${summary.dead} dead worker(s).`,
|
|
389
|
+
"Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
|
|
390
|
+
);
|
|
391
|
+
maybeNotifyHealth(
|
|
392
|
+
"recovery_missing_heartbeat",
|
|
393
|
+
summary.missing,
|
|
394
|
+
`Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
|
|
395
|
+
"Open /team-dashboard → 5 health → inspect health actions.",
|
|
396
|
+
);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
logInternalError("register.health-notification", error, run.runId);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
|
|
404
|
+
const liveRefreshMs = 160;
|
|
405
|
+
const hasActiveWork = (): boolean => {
|
|
406
|
+
if (listLiveAgents().some((a) => a.status === "running")) return true;
|
|
407
|
+
return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
|
|
408
|
+
};
|
|
409
|
+
const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
|
|
410
|
+
ctx.renderScheduler = new RenderScheduler(pi.events, renderTick, {
|
|
411
|
+
fallbackMs: effectiveRefreshMs,
|
|
412
|
+
onInvalidate: (payload: unknown) => {
|
|
413
|
+
const runId =
|
|
414
|
+
typeof payload === "object" &&
|
|
415
|
+
payload !== null &&
|
|
416
|
+
"runId" in payload &&
|
|
417
|
+
typeof (payload as { runId: unknown }).runId === "string"
|
|
418
|
+
? (payload as { runId: string }).runId
|
|
419
|
+
: undefined;
|
|
420
|
+
// FLICKER FIX: never hard-delete snapshot entries from a render-scheduler
|
|
421
|
+
// invalidate. A no-runId payload — emitted by EVERY fallback tick
|
|
422
|
+
// (~every 160ms while a run is active) — previously ran
|
|
423
|
+
// `invalidate(undefined)` → `entries.clear()`, wiping ALL snapshots.
|
|
424
|
+
// The next `renderTick` then saw `get() === undefined` for every run,
|
|
425
|
+
// so `activeWidgetRuns` dropped them to "(loading…)" until the async
|
|
426
|
+
// preload rebuilt the cache — an endless visible flicker. For a
|
|
427
|
+
// specific runId we now refresh-if-stale (stale-while-revalidate) so
|
|
428
|
+
// the widget always sees a populated snapshot; a no-runId tick does
|
|
429
|
+
// nothing (renderTick itself repaints; the cache's own
|
|
430
|
+
// run:state/worker:lifecycle subscription refreshes affected runs).
|
|
431
|
+
if (!runId) return;
|
|
432
|
+
try {
|
|
433
|
+
ctx.getRunSnapshotCache(extensionCtx.cwd).refreshIfStale(runId);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
logInternalError("register.renderScheduler.refresh", error, runId);
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
// Fix D: bridge internal runEventBus events to renderScheduler so the UI
|
|
440
|
+
// re-renders within debounceMs of any agent lifecycle event.
|
|
441
|
+
const sched = ctx.renderScheduler;
|
|
442
|
+
const unsubscribeRunEvents = runEventBus.onAny((event) => {
|
|
443
|
+
// bug-026 sub-issue C: evict terminal runs from the preloaded manifest
|
|
444
|
+
// frame so a stale "running" entry cannot persist for the session
|
|
445
|
+
// lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2 snapshot
|
|
446
|
+
// purges (which invalidate the snapshot cache) — those gates do not
|
|
447
|
+
// touch lastPreloadedManifests itself.
|
|
448
|
+
lastPreloadedManifests = applyTerminalRunEventToManifests(lastPreloadedManifests, event);
|
|
449
|
+
sched.schedule({
|
|
450
|
+
runId: event.runId,
|
|
451
|
+
source: "runEventBus",
|
|
452
|
+
type: event.type,
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
ctx.renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
|
|
456
|
+
startPreloadLoop(fallbackMs, effectiveRefreshMs);
|
|
457
|
+
|
|
458
|
+
// Bounded run watcher setup (pts/2 hang fix 2026-06-16).
|
|
459
|
+
const crewRunWatcherOnChange = (runId: string): void => {
|
|
460
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
|
|
461
|
+
// FLICKER FIX: rebuild-in-place instead of deleting the entry (see
|
|
462
|
+
// onRunChange above). A hard delete left `get()` returning undefined for
|
|
463
|
+
// a frame, dropping the run to "(loading…)" and causing visible flicker.
|
|
464
|
+
// PERF (2026-08-24): coalesced ASYNC refresh — fs.watch can fire many
|
|
465
|
+
// times per second and the sync rebuild blocked the UI event loop; the
|
|
466
|
+
// render schedule below repaints while the rebuild lands.
|
|
467
|
+
try {
|
|
468
|
+
ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
logInternalError("register.crewRunWatcher.refresh", error, runId);
|
|
471
|
+
}
|
|
472
|
+
ctx.renderScheduler?.schedule({ runId });
|
|
473
|
+
};
|
|
474
|
+
const crewRunWatcherOnError = (error: unknown): void => {
|
|
475
|
+
logInternalError("register.crewRunWatchers.error", error);
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
ctx.crewRunWatchers?.closeAll();
|
|
479
|
+
ctx.crewRunWatchers = undefined;
|
|
480
|
+
const crewRunsDir = path.join(projectCrewRoot(extensionCtx.cwd), "state", "runs");
|
|
481
|
+
if (fs.existsSync(crewRunsDir)) {
|
|
482
|
+
ctx.crewRunWatchers = new RunWatcherRegistry();
|
|
483
|
+
ctx.crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
logInternalError("register.crewRunWatchers.start", error);
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
ctx.userCrewWatchers?.closeAll();
|
|
490
|
+
ctx.userCrewWatchers = undefined;
|
|
491
|
+
const userRunsDir = path.join(userCrewRoot(), "state", "runs");
|
|
492
|
+
if (fs.existsSync(userRunsDir)) {
|
|
493
|
+
ctx.userCrewWatchers = new RunWatcherRegistry();
|
|
494
|
+
ctx.userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
|
|
495
|
+
}
|
|
496
|
+
} catch (error) {
|
|
497
|
+
logInternalError("register.userCrewWatchers.start", error);
|
|
498
|
+
}
|
|
499
|
+
// Kick an immediate preload so the first buildFrame reconciles per-run
|
|
500
|
+
// watchers for any runs that are already active on session start.
|
|
501
|
+
backgroundPreload();
|
|
502
|
+
}
|
|
@@ -865,6 +865,9 @@ export async function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue
|
|
|
865
865
|
// cleanup only closes multiplexer panes (zombie workers' panes + terminal
|
|
866
866
|
// runs' leaked panes) through the provider, gated on detect(). The user's
|
|
867
867
|
// main session never carries PI_CREW_KIND, so it can never appear here.
|
|
868
|
+
// R3-16: the scan ALSO reports "foreign" pi processes (AI_AGENT=pi /
|
|
869
|
+
// PI_CODING_AGENT=true CLI markers + pi-looking argv) — WARN listing only,
|
|
870
|
+
// never kill candidates, never fed to pane cleanup.
|
|
868
871
|
if (params.focus === "zombies") {
|
|
869
872
|
const scan = scanZombieSubagents();
|
|
870
873
|
const cleanup = await cleanupOrphanSurfacePanes({ cwd: ctx.cwd, scan });
|
|
@@ -877,6 +880,9 @@ export async function handleDoctor(ctx: TeamContext, params: TeamToolParamsValue
|
|
|
877
880
|
data: {
|
|
878
881
|
zombies: scan.zombies.length,
|
|
879
882
|
live: scan.live.length,
|
|
883
|
+
// R3-16: foreign pi processes — WARN-only report count (never killed,
|
|
884
|
+
// never pane-cleaned; see zombie-scanner.ts discriminator notes).
|
|
885
|
+
foreignPi: scan.foreign.length,
|
|
880
886
|
errors: scan.errors.length,
|
|
881
887
|
orphanPanes: cleanup.orphans.length,
|
|
882
888
|
panesClosed: cleanup.closed.length,
|
|
@@ -16,6 +16,7 @@ import { pollWorkerInbox } from "./inbox-poll.ts";
|
|
|
16
16
|
import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
|
|
17
17
|
import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
|
|
18
18
|
import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
|
|
19
|
+
import { createWorkerEventsChannel } from "./worker-events-channel.ts";
|
|
19
20
|
|
|
20
21
|
export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
|
|
21
22
|
export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
|
|
@@ -653,10 +654,11 @@ async function resolvePark(
|
|
|
653
654
|
}
|
|
654
655
|
|
|
655
656
|
/** ADR item 10: every ask-timeout outcome appends `ask.timedout` to the run's
|
|
656
|
-
* events.jsonl.
|
|
657
|
-
*
|
|
658
|
-
* the
|
|
659
|
-
*
|
|
657
|
+
* events.jsonl. WP-9 made PI_CREW_EVENTS_PATH unconditional (this comment
|
|
658
|
+
* used to say it was scratchpad-gated — stale); this path predates that and
|
|
659
|
+
* keeps deriving the file from the state store invariant eventsPath ===
|
|
660
|
+
* <stateRoot>/events.jsonl — the same invariant child-pi-spawn relies on to
|
|
661
|
+
* derive PI_CREW_STATE_ROOT. Fire-and-forget: never blocks the tool result. */
|
|
660
662
|
function emitAskTimedOutEvent(stateRoot: string, runId: string, taskId: string, questionId: string): void {
|
|
661
663
|
appendEventFireAndForget(path.join(stateRoot, "events.jsonl"), {
|
|
662
664
|
type: "ask.timedout",
|
|
@@ -1030,6 +1032,33 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
|
|
|
1030
1032
|
void brokerHandle.close().catch(() => undefined);
|
|
1031
1033
|
});
|
|
1032
1034
|
|
|
1035
|
+
// ── W5 (P2-2): observe worker auto-compaction ──────────────────────────
|
|
1036
|
+
// Auto-compaction inside a worker is otherwise silent from the
|
|
1037
|
+
// orchestrator's view (perf reports only see token jumps). The SDK fires
|
|
1038
|
+
// `session_before_compact` right before the host pi compacts the session
|
|
1039
|
+
// context; this listener OBSERVES ONLY — it returns undefined so the host
|
|
1040
|
+
// proceeds with its own preparation untouched (host analog:
|
|
1041
|
+
// compaction-guard.ts `pi.on("session_before_compact", async () => { return; })`).
|
|
1042
|
+
// NAME DISCIPLINE: this is the SDK's SESSION compaction (host context
|
|
1043
|
+
// summarization) — NOT pi-crew's internal `prepareCompaction` event-log
|
|
1044
|
+
// rotation (event-log-rotation.ts); same word, different mechanism.
|
|
1045
|
+
// Channel discipline (WP-9): createWorkerEventsChannel is registered
|
|
1046
|
+
// unconditionally and self-no-ops without PI_CREW_EVENTS_PATH/
|
|
1047
|
+
// PI_CREW_BROKER_RUN_ID, so a main user session pays nothing. Payload is
|
|
1048
|
+
// scalar-only: branchEntries (large) and signal (AbortSignal) are never
|
|
1049
|
+
// serialized; preparation fields are read defensively (SDK shape evolves).
|
|
1050
|
+
const compactEventsChannel = createWorkerEventsChannel();
|
|
1051
|
+
pi.on("session_before_compact", (event) => {
|
|
1052
|
+
const tokensBefore = (event.preparation as { tokensBefore?: unknown } | undefined)?.tokensBefore;
|
|
1053
|
+
compactEventsChannel.emit("worker.session_before_compact", {
|
|
1054
|
+
reason: event.reason,
|
|
1055
|
+
willRetry: event.willRetry,
|
|
1056
|
+
branchEntryCount: event.branchEntries?.length ?? 0,
|
|
1057
|
+
hasCustomInstructions: typeof event.customInstructions === "string",
|
|
1058
|
+
...(typeof tokensBefore === "number" ? { tokensBefore } : {}),
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1033
1062
|
// ── Task 5 (§15.2): worker inbox pickup ────────────────────────────────
|
|
1034
1063
|
// A broker-eligible worker ALSO picks up sibling DMs / group broadcasts
|
|
1035
1064
|
// (`kind:"message"` entries addressed to this task in the durable
|
|
@@ -270,10 +270,24 @@ export function prepareSpawnContext(
|
|
|
270
270
|
agent: input.agent,
|
|
271
271
|
model: input.model,
|
|
272
272
|
sessionEnabled: true,
|
|
273
|
+
// R3-15: human-facing `--name crew-<taskId>` session label (agentId IS the
|
|
274
|
+
// task id; anonymous spawns without one simply get no name — same
|
|
275
|
+
// optional-shape as sessionId/sessionDir above).
|
|
276
|
+
taskId: input.agentId,
|
|
277
|
+
// W2 (P1-1): deterministic session identity so crash-path tail-recovery
|
|
278
|
+
// can find the worker's session JSONL (see session-recovery.ts). Falls
|
|
279
|
+
// back to child-pi.ts deriveSessionPaths when unset (idempotent both ways).
|
|
280
|
+
sessionId: input.sessionId,
|
|
281
|
+
sessionDir: input.sessionDir,
|
|
273
282
|
maxDepth: input.maxDepth,
|
|
274
283
|
skillPaths: input.skillPaths,
|
|
275
284
|
role: input.role,
|
|
276
285
|
thinkingOverride: input.thinkingOverride,
|
|
286
|
+
systemPromptAppend: input.systemPromptAppend,
|
|
287
|
+
hermeticWorkers: input.hermeticWorkers,
|
|
288
|
+
// U9: version-gated flags (--no-mcp floor) — threaded by runChildPi's
|
|
289
|
+
// single-flight probe; undefined/null = unknown → builder emits nothing.
|
|
290
|
+
piVersion: input.piVersion,
|
|
277
291
|
// ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
|
|
278
292
|
// (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
|
|
279
293
|
// child env gets parentDepth+1 = the true grandchild depth.
|