pi-crew 0.9.41 → 0.9.44
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 +253 -0
- package/README.md +14 -0
- package/dist/build-meta.json +6450 -12419
- package/dist/index.mjs +57998 -68417
- package/dist/index.mjs.map +4 -4
- package/docs/migration/atomic-write-v2-migration.md +2 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
- package/package.json +4 -2
- package/scripts/README.md +6 -0
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/check-all-skills.ts +3 -3
- package/scripts/dev-runner.mjs +106 -0
- package/scripts/postinstall.mjs +6 -2
- package/scripts/verify-skill.ts +2 -2
- package/src/agents/agent-config.ts +1 -1
- package/src/config/config.ts +20 -1
- package/src/config/drift-detector.ts +97 -15
- package/src/config/resilient-parser.ts +4 -4
- package/src/config/types.ts +0 -2
- package/src/extension/crew-cleanup.ts +17 -3
- package/src/extension/crew-vibes/config.ts +1 -1
- package/src/extension/crew-vibes/font-detect.ts +16 -3
- package/src/extension/crew-vibes/index.ts +1 -1
- package/src/extension/cross-extension-rpc.ts +26 -9
- package/src/extension/management.ts +6 -5
- package/src/extension/register.ts +58 -1573
- package/src/extension/registration/command-registration.ts +57 -0
- package/src/extension/registration/commands.ts +7 -2
- package/src/extension/registration/context-builder.ts +131 -0
- package/src/extension/registration/crash-recovery-cache.ts +54 -0
- package/src/extension/registration/foreground-run-controller.ts +238 -0
- package/src/extension/registration/hook-registration.ts +119 -0
- package/src/extension/registration/lazy-configurers.ts +124 -0
- package/src/extension/registration/lifecycle-handlers.ts +731 -0
- package/src/extension/registration/observability.ts +33 -4
- package/src/extension/registration/registration-types.ts +149 -0
- package/src/extension/registration/runtime-cleanup.ts +230 -0
- package/src/extension/registration/subagent-manager-setup.ts +211 -0
- package/src/extension/registration/tool-registration.ts +50 -0
- package/src/extension/registration/wire-cross-extension.ts +37 -0
- package/src/extension/rpc-hmac.ts +21 -1
- package/src/extension/run-import.ts +3 -3
- package/src/extension/team-tool/auto-summarize.ts +4 -4
- package/src/extension/team-tool/cancel.ts +5 -5
- package/src/extension/team-tool/chain-executor.ts +5 -4
- package/src/extension/team-tool/context.ts +11 -1
- package/src/extension/team-tool/doctor.ts +2 -1
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +25 -36
- package/src/extension/team-tool/orchestrate.ts +0 -1
- package/src/extension/team-tool/parallel-dispatch.ts +5 -0
- package/src/extension/team-tool/run.ts +220 -214
- package/src/extension/team-tool/workflow-manage.ts +1 -1
- package/src/extension/team-tool.ts +69 -16
- package/src/hooks/registry.ts +7 -2
- package/src/observability/correlation.ts +4 -3
- package/src/observability/event-bus.ts +10 -1
- package/src/observability/event-to-metric.ts +8 -7
- package/src/observability/exporters/adapter.ts +6 -9
- package/src/observability/exporters/otlp-exporter.ts +81 -0
- package/src/observability/metric-retention.ts +4 -0
- package/src/observability/metric-sink.ts +14 -4
- package/src/prompt/prompt-runtime.ts +15 -0
- package/src/runtime/adaptive-plan.ts +1 -1
- package/src/runtime/async-runner.ts +13 -4
- package/src/runtime/background-runner.ts +32 -23
- package/src/runtime/capability-inventory.ts +1 -1
- package/src/runtime/chain-runner.ts +5 -5
- package/src/runtime/checkpoint.ts +6 -16
- package/src/runtime/child-pi-constants.ts +42 -0
- package/src/runtime/child-pi-kill.ts +180 -0
- package/src/runtime/child-pi-spawn.ts +234 -0
- package/src/runtime/child-pi-steering.ts +128 -0
- package/src/runtime/child-pi-streams.ts +296 -0
- package/src/runtime/child-pi-transcript.ts +169 -0
- package/src/runtime/child-pi.ts +80 -822
- package/src/runtime/coalesce-tasks.ts +1 -1
- package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
- package/src/runtime/crash-recovery.ts +63 -47
- package/src/runtime/delivery-coordinator.ts +1 -1
- package/src/runtime/deterministic-ast.ts +2 -2
- package/src/runtime/diagnostic-export.ts +2 -1
- package/src/runtime/dwf-state-store.ts +5 -0
- package/src/runtime/dynamic-workflow-context.ts +78 -18
- package/src/runtime/dynamic-workflow-runner.ts +18 -2
- package/src/runtime/foreground-control.ts +3 -2
- package/src/runtime/goal-evaluator.ts +59 -27
- package/src/runtime/goal-loop-runner.ts +46 -13
- package/src/runtime/goal-state-store.ts +1 -1
- package/src/runtime/handoff-manager.ts +0 -2
- package/src/runtime/intercom-bridge.ts +0 -2
- package/src/runtime/live-agent-manager.ts +30 -5
- package/src/runtime/live-extension-bridge.ts +0 -2
- package/src/runtime/live-session-runtime.ts +65 -57
- package/src/runtime/manifest-cache.ts +59 -0
- package/src/runtime/mcp-proxy.ts +1 -1
- package/src/runtime/orphan-worker-registry.ts +3 -3
- package/src/runtime/output-validator.ts +1 -1
- package/src/runtime/path-overlap.ts +1 -1
- package/src/runtime/pi-args.ts +3 -3
- package/src/runtime/pi-json-output.ts +4 -3
- package/src/runtime/pi-spawn.ts +7 -1
- package/src/runtime/pipeline-runner.ts +1 -4
- package/src/runtime/post-checks.ts +0 -1
- package/src/runtime/retry-runner.ts +1 -1
- package/src/runtime/run-coalesced-task-group.ts +18 -23
- package/src/runtime/scheduler.ts +2 -0
- package/src/runtime/session-resources.ts +1 -1
- package/src/runtime/settings-store.ts +31 -1
- package/src/runtime/skill-effectiveness.ts +2 -2
- package/src/runtime/stale-reconciler.ts +23 -53
- package/src/runtime/subagent-manager.ts +19 -3
- package/src/runtime/task-output-context.ts +2 -1
- package/src/runtime/task-runner/prompt-builder.ts +3 -3
- package/src/runtime/task-runner/run-projection.ts +3 -1
- package/src/runtime/task-runner/state-helpers.ts +13 -1
- package/src/runtime/task-runner.ts +31 -6
- package/src/runtime/team-runner.ts +8 -2
- package/src/runtime/verification-gates.ts +54 -5
- package/src/schema/config-schema.ts +19 -1
- package/src/schema/team-tool-schema.ts +2 -0
- package/src/skills/discover-skills.ts +25 -15
- package/src/state/active-run-registry.ts +0 -1
- package/src/state/atomic-write.ts +37 -11
- package/src/state/blob-store.ts +3 -9
- package/src/state/contracts.ts +5 -1
- package/src/state/crew-init.ts +3 -2
- package/src/state/decision-ledger.ts +2 -2
- package/src/state/event-log-rotation.ts +1 -1
- package/src/state/event-log.ts +4 -3
- package/src/state/gitignore-manager.ts +2 -1
- package/src/state/health-store.ts +5 -2
- package/src/state/instinct-store.ts +11 -2
- package/src/state/locks.ts +132 -49
- package/src/state/mailbox.ts +3 -4
- package/src/state/observation-store.ts +1 -13
- package/src/state/run-cache.ts +12 -12
- package/src/state/run-graph.ts +2 -1
- package/src/state/run-metrics.ts +1 -1
- package/src/state/schedule.ts +7 -1
- package/src/state/state-store.ts +9 -3
- package/src/state/tiered-eval.ts +1 -1
- package/src/state/worker-atomic-writer.ts +1 -0
- package/src/ui/dynamic-border.ts +13 -3
- package/src/ui/live-conversation-overlay.ts +7 -27
- package/src/ui/live-duration.ts +2 -2
- package/src/ui/live-run-sidebar.ts +56 -32
- package/src/ui/mascot.ts +19 -13
- package/src/ui/powerbar-publisher.ts +244 -178
- package/src/ui/render-coalescer.ts +44 -3
- package/src/ui/render-diff.ts +2 -2
- package/src/ui/run-dashboard.ts +122 -21
- package/src/ui/run-event-bus.ts +66 -0
- package/src/ui/run-snapshot-cache.ts +84 -81
- package/src/ui/settings-overlay.ts +3 -3
- package/src/ui/status-colors.ts +24 -0
- package/src/ui/tool-progress-formatter.ts +1 -1
- package/src/ui/tool-render.ts +3 -3
- package/src/ui/transcript-cache.ts +5 -10
- package/src/ui/transcript-entries.ts +68 -72
- package/src/ui/transcript-viewer.ts +54 -14
- package/src/ui/widget/index.ts +26 -15
- package/src/ui/widget/widget-model.ts +18 -9
- package/src/ui/widget/widget-renderer.ts +2 -5
- package/src/utils/fingerprint.ts +1 -1
- package/src/utils/paths.ts +1 -1
- package/src/utils/project-detector.ts +1 -1
- package/src/worktree/worktree-manager.ts +47 -19
- package/src/state/atomic-write-v2.ts +0 -108
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle handler installer for pi-crew.
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* • `session_start` — heavy setup (config, scheduler, render loop, watchers,
|
|
6
|
+
* deferred crash recovery). This is the bulk of the orchestrator's
|
|
7
|
+
* per-session work, extracted here so `register.ts` stays thin.
|
|
8
|
+
* • `session_shutdown` — reason-aware cleanup (quit/reload aborts
|
|
9
|
+
* foreground runs; resume/new/fork preserves them).
|
|
10
|
+
* • `session_before_switch` — graceful session switch handoff.
|
|
11
|
+
*
|
|
12
|
+
* Imports here are kept top-level (non-lazy) on purpose: this module IS
|
|
13
|
+
* where the heavy work happens, so there is no cold-start benefit to
|
|
14
|
+
* deferring it. The session_start handler internally uses lazy imports
|
|
15
|
+
* for its own per-call optional work (foreground-watchdog, atomic-write).
|
|
16
|
+
*/
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { loadConfig } from "../../config/config.ts";
|
|
21
|
+
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
22
|
+
import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
|
|
23
|
+
import { reconcileAllStaleRuns } from "../../runtime/crash-recovery.ts";
|
|
24
|
+
import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
|
|
25
|
+
import type { createManifestCache } from "../../runtime/manifest-cache.ts";
|
|
26
|
+
import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
|
|
27
|
+
import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs } from "../../runtime/pi-args.ts";
|
|
28
|
+
import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduler.ts";
|
|
29
|
+
import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
|
|
30
|
+
import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
|
|
31
|
+
import { applyCrewSettingsToConfig, loadCrewSettings } from "../../runtime/settings-store.ts";
|
|
32
|
+
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
33
|
+
import type { TeamRunManifest } from "../../state/types.ts";
|
|
34
|
+
import { terminateActiveChildPiProcesses } from "../../subagents/spawn.ts";
|
|
35
|
+
import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
|
|
36
|
+
import { requestRender, setExtensionWidget } from "../../ui/pi-ui-compat.ts";
|
|
37
|
+
import {
|
|
38
|
+
registerPiCrewPowerbarSegments,
|
|
39
|
+
requestPowerbarUpdate,
|
|
40
|
+
resetPowerbarDedupState,
|
|
41
|
+
updatePiCrewPowerbar,
|
|
42
|
+
} from "../../ui/powerbar-publisher.ts";
|
|
43
|
+
import { RenderScheduler } from "../../ui/render-scheduler.ts";
|
|
44
|
+
import { runEventBus } from "../../ui/run-event-bus.ts";
|
|
45
|
+
import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
|
|
46
|
+
import { updateCrewWidget } from "../../ui/widget/index.ts";
|
|
47
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
48
|
+
import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
|
|
49
|
+
import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
|
|
50
|
+
import { extractSessionId } from "../../utils/session-utils.ts";
|
|
51
|
+
import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
|
|
52
|
+
import { registerCrewAutocomplete } from "../crew-autocomplete.ts";
|
|
53
|
+
import { notifyActiveRuns } from "../session-summary.ts";
|
|
54
|
+
import { persistScheduledJobUpdate } from "../team-tool/handle-schedule.ts";
|
|
55
|
+
import { handleTeamTool } from "../team-tool.ts";
|
|
56
|
+
import { runArtifactCleanup } from "./artifact-cleanup.ts";
|
|
57
|
+
import type { RegistrationContext } from "./registration-types.ts";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register all session-lifecycle handlers on the ExtensionAPI. The caller
|
|
61
|
+
* (`register.ts`) must have already wired the orchestrator-side cleanup
|
|
62
|
+
* functions into `ctx.cleanupRuntime` and `ctx.cleanupSessionResourcesOnly`.
|
|
63
|
+
*/
|
|
64
|
+
export function installSessionLifecycleHandlers(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
65
|
+
installSessionShutdownHandler(pi, ctx);
|
|
66
|
+
installSessionStartHandler(pi, ctx);
|
|
67
|
+
installSessionBeforeSwitchHandler(pi, ctx);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* session_shutdown:
|
|
72
|
+
* • reason="quit" / "reload" → full cleanup (abort foreground runs).
|
|
73
|
+
* • reason="resume" / "new" / "fork" → resource cleanup only (preserve
|
|
74
|
+
* foreground runs; they share the process with the session).
|
|
75
|
+
*/
|
|
76
|
+
function installSessionShutdownHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
77
|
+
pi.on("session_shutdown", (event) => {
|
|
78
|
+
const reason = typeof event === "object" && event !== null && "reason" in event ? (event as { reason: string }).reason : undefined;
|
|
79
|
+
if (reason === "quit" || reason === "reload") {
|
|
80
|
+
// Actual shutdown — abort foreground runs and cleanup everything
|
|
81
|
+
ctx.cleanupRuntime();
|
|
82
|
+
} else {
|
|
83
|
+
// Session switch (resume/new/fork) — cleanup resources but preserve foreground runs
|
|
84
|
+
ctx.cleanupSessionResourcesOnly();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* session_before_switch:
|
|
91
|
+
* Bump generation, deactivate delivery coordinator, stop async notifier,
|
|
92
|
+
* abort session-bound subagents. Foreground team runs are NOT aborted here.
|
|
93
|
+
*/
|
|
94
|
+
function installSessionBeforeSwitchHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
95
|
+
pi.on("session_before_switch", () => {
|
|
96
|
+
ctx.sessionGeneration++;
|
|
97
|
+
const pendingCount = ctx.lifecycleState.deliveryCoordinator?.getPendingCount() ?? 0;
|
|
98
|
+
try {
|
|
99
|
+
const activeRuns = ctx.currentCtx
|
|
100
|
+
? ctx
|
|
101
|
+
.getManifestCache(ctx.currentCtx.cwd)
|
|
102
|
+
.list(50)
|
|
103
|
+
.filter((run) => run.status === "running" || run.status === "queued" || run.status === "blocked")
|
|
104
|
+
: [];
|
|
105
|
+
const snapshot = createSessionSnapshot(activeRuns, pendingCount, ctx.sessionGeneration);
|
|
106
|
+
if (pendingCount > 0 || snapshot.activeRunIds.length > 0)
|
|
107
|
+
logInternalError("register.session-before-switch", undefined, JSON.stringify(snapshot));
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logInternalError("register.session-before-switch.snapshot", error);
|
|
110
|
+
}
|
|
111
|
+
if (pendingCount > 0) {
|
|
112
|
+
logInternalError("register.session-before-switch", `Switching session with ${pendingCount} pending deliveries`);
|
|
113
|
+
}
|
|
114
|
+
ctx.lifecycleState.deliveryCoordinator?.deactivate();
|
|
115
|
+
resetPowerbarDedupState();
|
|
116
|
+
stopAsyncRunNotifier(ctx.notifierState);
|
|
117
|
+
ctx.stopSessionBoundSubagents();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* session_start — the bulk of pi-crew's per-session work.
|
|
123
|
+
*
|
|
124
|
+
* Pipeline:
|
|
125
|
+
* 1. Resolve session metadata + restore brief mode (best-effort).
|
|
126
|
+
* 2. Bump generation, set currentCtx, register autocomplete (once).
|
|
127
|
+
* 3. Schedule deferred crash recovery (orphan cleanup, stale-reconcile,
|
|
128
|
+
* auto-prune). MUST run in setTimeout(0) — these block 100ms-1s on
|
|
129
|
+
* Windows and cannot stall the session_start event.
|
|
130
|
+
* 4. Synchronously: load config + crew settings, start CrewScheduler,
|
|
131
|
+
* configure notifications/observability/delivery-coordinator,
|
|
132
|
+
* register Pi-side powerbar segments, start async notifier,
|
|
133
|
+
* kick off the render scheduler + preload loop + bounded watchers.
|
|
134
|
+
*/
|
|
135
|
+
function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext): void {
|
|
136
|
+
pi.on("session_start", (_event, extensionCtx) => {
|
|
137
|
+
runArtifactCleanup(extensionCtx.cwd);
|
|
138
|
+
|
|
139
|
+
// Restore brief mode state from session entries (best-effort).
|
|
140
|
+
try {
|
|
141
|
+
const entries = extensionCtx.sessionManager?.getEntries?.();
|
|
142
|
+
if (entries) {
|
|
143
|
+
// LAZY: brief-mode is only used inside the session-start restore path.
|
|
144
|
+
import("../../ui/tool-renderers/brief-mode.ts")
|
|
145
|
+
.then(({ restoreBriefState }) => {
|
|
146
|
+
restoreBriefState(entries);
|
|
147
|
+
})
|
|
148
|
+
.catch(() => {
|
|
149
|
+
/* non-critical */
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
/* non-critical */
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
ctx.cleanedUp = false;
|
|
157
|
+
ctx.sessionGeneration++;
|
|
158
|
+
const ownerGeneration = ctx.sessionGeneration;
|
|
159
|
+
ctx.currentCtx = extensionCtx;
|
|
160
|
+
// Round 13 UX: register the crew natural-language autocomplete provider
|
|
161
|
+
// once we have a UI context. Guarded so repeated session_start events
|
|
162
|
+
// don't stack wrappers (each wrapper delegates, but stacking wastes
|
|
163
|
+
// call depth).
|
|
164
|
+
if (!ctx.crewAutocompleteRegistered) {
|
|
165
|
+
ctx.crewAutocompleteRegistered = true;
|
|
166
|
+
registerCrewAutocomplete(extensionCtx);
|
|
167
|
+
}
|
|
168
|
+
if (ctx.widgetState.interval) clearInterval(ctx.widgetState.interval);
|
|
169
|
+
ctx.widgetState.interval = undefined;
|
|
170
|
+
notifyActiveRuns(extensionCtx);
|
|
171
|
+
|
|
172
|
+
const currentSessionId = extractSessionId(extensionCtx);
|
|
173
|
+
|
|
174
|
+
// Defer ALL heavy cleanup to after the session_start handler returns.
|
|
175
|
+
// These operations involve synchronous directory scanning (readdirSync, readFileSync)
|
|
176
|
+
// which can take 100ms–1s+ on Windows. They MUST NOT block the session_start event.
|
|
177
|
+
setTimeout(() => {
|
|
178
|
+
void runDeferredSessionCleanup(pi, ctx, ownerGeneration, currentSessionId, extensionCtx);
|
|
179
|
+
}, 0);
|
|
180
|
+
|
|
181
|
+
const loadedConfig = loadConfig(extensionCtx.cwd);
|
|
182
|
+
const crewSettings = loadCrewSettings(extensionCtx.cwd);
|
|
183
|
+
applyCrewSettingsToConfig(loadedConfig.config, crewSettings);
|
|
184
|
+
|
|
185
|
+
// Start scheduler with event-based executor
|
|
186
|
+
const sessionId =
|
|
187
|
+
extensionCtx.sessionManager?.getSessionId?.() ??
|
|
188
|
+
(typeof extensionCtx === "object" && extensionCtx !== null && "sessionId" in extensionCtx
|
|
189
|
+
? (extensionCtx as Record<string, unknown>).sessionId
|
|
190
|
+
: undefined);
|
|
191
|
+
ctx.crewScheduler = setupCrewScheduler(pi, ctx, extensionCtx, sessionId);
|
|
192
|
+
|
|
193
|
+
// Wire scheduler into handle-schedule.ts so handlers can add/list jobs.
|
|
194
|
+
// Uses a global symbol so the module doesn't need a direct circular import.
|
|
195
|
+
(globalThis as Record<symbol | string, unknown>)[Symbol.for("pi-crew:scheduler")] = ctx.crewScheduler;
|
|
196
|
+
// Load scheduled jobs from settings if present
|
|
197
|
+
if (Array.isArray(crewSettings.scheduledJobs)) {
|
|
198
|
+
for (const job of crewSettings.scheduledJobs) {
|
|
199
|
+
try {
|
|
200
|
+
ctx.crewScheduler.add(job as ScheduledJob);
|
|
201
|
+
} catch {
|
|
202
|
+
/* skip invalid */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
ctx.autoRecoveryLast.clear();
|
|
207
|
+
ctx.configureNotifications(extensionCtx);
|
|
208
|
+
ctx.configureObservability(extensionCtx);
|
|
209
|
+
ctx.configureDeliveryCoordinator();
|
|
210
|
+
if (typeof sessionId === "string" && sessionId) ctx.lifecycleState.deliveryCoordinator?.activate(sessionId);
|
|
211
|
+
tryRegisterSessionCleanup(pi, () => {
|
|
212
|
+
terminateActiveChildPiProcesses();
|
|
213
|
+
ctx.cleanupRuntime();
|
|
214
|
+
});
|
|
215
|
+
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
216
|
+
startAsyncRunNotifier(extensionCtx, ctx.notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
|
|
217
|
+
generation: ownerGeneration,
|
|
218
|
+
isCurrent: (generation) => generation === ctx.sessionGeneration && ctx.currentCtx === extensionCtx && !ctx.cleanedUp,
|
|
219
|
+
});
|
|
220
|
+
const cache = ctx.getManifestCache(extensionCtx.cwd);
|
|
221
|
+
updateCrewWidget(extensionCtx, ctx.widgetState, loadedConfig.config.ui, cache, ctx.getRunSnapshotCache(extensionCtx.cwd));
|
|
222
|
+
updatePiCrewPowerbar(
|
|
223
|
+
pi.events,
|
|
224
|
+
extensionCtx.cwd,
|
|
225
|
+
loadedConfig.config.ui,
|
|
226
|
+
cache,
|
|
227
|
+
ctx.getRunSnapshotCache(extensionCtx.cwd),
|
|
228
|
+
extensionCtx,
|
|
229
|
+
ctx.widgetState.notificationCount ?? 0,
|
|
230
|
+
);
|
|
231
|
+
setupRenderLoop(pi, ctx, extensionCtx, loadedConfig);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Heavy cleanup that runs after session_start returns.
|
|
237
|
+
*
|
|
238
|
+
* Wrapped in setTimeout(0) so the session_start event is not blocked by
|
|
239
|
+
* the synchronous I/O involved (readdirSync, readFileSync) — observed to
|
|
240
|
+
* take 100ms-1s+ on Windows with many runs on disk.
|
|
241
|
+
*/
|
|
242
|
+
async function runDeferredSessionCleanup(
|
|
243
|
+
pi: ExtensionAPI,
|
|
244
|
+
ctx: RegistrationContext,
|
|
245
|
+
ownerGeneration: number,
|
|
246
|
+
currentSessionId: string | undefined,
|
|
247
|
+
extensionCtx: ExtensionContext,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return; // session switched while we waited
|
|
250
|
+
|
|
251
|
+
// 2.7: load crash-recovery lazily once per session_start cleanup batch.
|
|
252
|
+
let crashRecovery: Awaited<ReturnType<typeof ctx.importCrashRecovery>> | undefined;
|
|
253
|
+
try {
|
|
254
|
+
crashRecovery = await ctx.importCrashRecovery();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
logInternalError("register.sessionStart.lazyCrashRecovery", error);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
|
|
260
|
+
const { cancelOrphanedRuns: cancelOrphanedRunsFn, purgeStaleActiveRunIndex: purgeStaleActiveRunIndexFn } = crashRecovery;
|
|
261
|
+
|
|
262
|
+
// Auto-cancel orphaned runs
|
|
263
|
+
if (currentSessionId) {
|
|
264
|
+
try {
|
|
265
|
+
const { cancelled } = (
|
|
266
|
+
cancelOrphanedRunsFn as (
|
|
267
|
+
cwd: string,
|
|
268
|
+
cache: ReturnType<typeof createManifestCache>,
|
|
269
|
+
sessionId: string,
|
|
270
|
+
) => { cancelled: string[] }
|
|
271
|
+
)(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), currentSessionId);
|
|
272
|
+
if (cancelled.length > 0) {
|
|
273
|
+
ctx.notifyOperator({
|
|
274
|
+
id: `orphan_cleanup`,
|
|
275
|
+
severity: "info",
|
|
276
|
+
source: "crash-recovery",
|
|
277
|
+
title: `Cleaned up ${cancelled.length} orphaned run(s)`,
|
|
278
|
+
body: `Runs from previous sessions were auto-cancelled: ${cancelled.join(", ")}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logInternalError("register.sessionStart.orphanCleanup", error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Startup cleanup (Fix A): run orphan-temp-dir cleanup
|
|
287
|
+
try {
|
|
288
|
+
const orphanTmp = cleanupOrphanTempDirs();
|
|
289
|
+
const legacyTmp = cleanupLegacyOrphanTempDirs();
|
|
290
|
+
if (orphanTmp.cleaned > 0 || legacyTmp.cleaned > 0) {
|
|
291
|
+
ctx.notifyOperator({
|
|
292
|
+
id: `startup_temp_cleanup_${Date.now()}`,
|
|
293
|
+
severity: "info",
|
|
294
|
+
source: "temp-cleanup",
|
|
295
|
+
title: `Startup cleanup: removed ${orphanTmp.cleaned + legacyTmp.cleaned} orphan temp dir(s)`,
|
|
296
|
+
body: `${orphanTmp.cleaned} from ~/.pi/agent/pi-crew/tmp/ + ${legacyTmp.cleaned} legacy /tmp/pi-crew-*`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logInternalError("register.sessionStart.startupTempCleanup", error);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Orphan worker cleanup (Fix B): kill stale background-runner processes
|
|
304
|
+
try {
|
|
305
|
+
const orphanWorkers = cleanupOrphanWorkers(currentSessionId);
|
|
306
|
+
if (orphanWorkers.killed > 0) {
|
|
307
|
+
ctx.notifyOperator({
|
|
308
|
+
id: `orphan_workers_cleanup`,
|
|
309
|
+
severity: "info",
|
|
310
|
+
source: "worker-cleanup",
|
|
311
|
+
title: `Cleaned up ${orphanWorkers.killed} orphan worker(s)`,
|
|
312
|
+
body: `Background workers from previous (SIGKILL'd) sessions were terminated (pruned ${orphanWorkers.pruned} dead, kept ${orphanWorkers.kept}).`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
} catch (error) {
|
|
316
|
+
logInternalError("register.sessionStart.orphanWorkers", error);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Global purge of stale active-run-index entries
|
|
320
|
+
try {
|
|
321
|
+
const { purged } = purgeStaleActiveRunIndexFn();
|
|
322
|
+
if (purged.length > 0) {
|
|
323
|
+
ctx.notifyOperator({
|
|
324
|
+
id: `active_index_purge`,
|
|
325
|
+
severity: "info",
|
|
326
|
+
source: "crash-recovery",
|
|
327
|
+
title: `Purged ${purged.length} stale active-run-index entr${purged.length === 1 ? "y" : "ies"}`,
|
|
328
|
+
body: `Cleaned up global active run index`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
} catch (error) {
|
|
332
|
+
logInternalError("register.sessionStart.globalIndexPurge", error);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Reconcile stale runs found on disk
|
|
336
|
+
try {
|
|
337
|
+
const staleResults = reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd)) ?? [];
|
|
338
|
+
if (staleResults.length > 0) {
|
|
339
|
+
ctx.notifyOperator({
|
|
340
|
+
id: "stale_reconcile",
|
|
341
|
+
severity: "info",
|
|
342
|
+
source: "crash-recovery",
|
|
343
|
+
title: `Reconciled ${staleResults.length} stale run(s)`,
|
|
344
|
+
body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
} catch (error) {
|
|
348
|
+
logInternalError("register.sessionStart.reconcileStale", error);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Auto-prune finished project-level run directories
|
|
352
|
+
try {
|
|
353
|
+
const { removed } = pruneFinishedRuns(extensionCtx.cwd, 10);
|
|
354
|
+
if (removed.length > 0) {
|
|
355
|
+
ctx.notifyOperator({
|
|
356
|
+
id: `auto_prune_project`,
|
|
357
|
+
severity: "info",
|
|
358
|
+
source: "run-maintenance",
|
|
359
|
+
title: `Auto-pruned ${removed.length} finished project run(s)`,
|
|
360
|
+
body: `Removed old finished runs: ${removed.join(", ")}`,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
} catch (error) {
|
|
364
|
+
logInternalError("register.sessionStart.autoPruneProject", error);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Auto-prune finished user-level run directories
|
|
368
|
+
try {
|
|
369
|
+
const { removed } = pruneUserLevelRuns(10);
|
|
370
|
+
if (removed.length > 0) {
|
|
371
|
+
ctx.notifyOperator({
|
|
372
|
+
id: `auto_prune_user`,
|
|
373
|
+
severity: "info",
|
|
374
|
+
source: "run-maintenance",
|
|
375
|
+
title: `Auto-pruned ${removed.length} finished user-level run(s)`,
|
|
376
|
+
body: `Removed old finished runs: ${removed.join(", ")}`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
logInternalError("register.sessionStart.autoPruneUser", error);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Build a CrewScheduler wired to the current session. The scheduler's
|
|
386
|
+
* executor closure invokes handleTeamTool lazily — the heavy team-tool
|
|
387
|
+
* import only fires when a scheduled job actually runs.
|
|
388
|
+
*/
|
|
389
|
+
function setupCrewScheduler(
|
|
390
|
+
pi: ExtensionAPI,
|
|
391
|
+
ctx: RegistrationContext,
|
|
392
|
+
extensionCtx: ExtensionContext,
|
|
393
|
+
sessionId: string | undefined,
|
|
394
|
+
): CrewScheduler {
|
|
395
|
+
const crewScheduler = new CrewScheduler();
|
|
396
|
+
crewScheduler.start({
|
|
397
|
+
emit: (event) => {
|
|
398
|
+
if (ctx.cleanedUp) return;
|
|
399
|
+
pi.events?.emit?.("crew-scheduler", event);
|
|
400
|
+
},
|
|
401
|
+
executor: (job) => {
|
|
402
|
+
let runParams: { action: string; team: string; goal: string };
|
|
403
|
+
try {
|
|
404
|
+
runParams = JSON.parse(job.prompt);
|
|
405
|
+
} catch {
|
|
406
|
+
runParams = {
|
|
407
|
+
action: "run",
|
|
408
|
+
team: "default",
|
|
409
|
+
goal: job.prompt,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (runParams.action !== "run") return `scheduled-${job.id}-${Date.now()}`;
|
|
413
|
+
const agentId = `scheduled-${job.id}-${Date.now()}`;
|
|
414
|
+
setImmediate(async () => {
|
|
415
|
+
try {
|
|
416
|
+
const runResult = await handleTeamTool(
|
|
417
|
+
{
|
|
418
|
+
action: "run",
|
|
419
|
+
team: runParams.team,
|
|
420
|
+
goal: runParams.goal,
|
|
421
|
+
async: true,
|
|
422
|
+
},
|
|
423
|
+
{ cwd: extensionCtx.cwd, sessionId },
|
|
424
|
+
);
|
|
425
|
+
const runId = runResult?.details?.runId;
|
|
426
|
+
if (runId && typeof runId === "string") {
|
|
427
|
+
crewScheduler?.recordSpawnedRun(job.id, runId);
|
|
428
|
+
// Update run manifest with scheduler provenance for traceability
|
|
429
|
+
try {
|
|
430
|
+
const cwd = extensionCtx.cwd ?? process.cwd();
|
|
431
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
432
|
+
if (loaded) {
|
|
433
|
+
// LAZY: defer dynamic import of atomic-write.ts to its call site.
|
|
434
|
+
const { atomicWriteJson } = await import("../../state/atomic-write.ts");
|
|
435
|
+
atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
|
|
436
|
+
...loaded.manifest,
|
|
437
|
+
schedulerJobId: job.id,
|
|
438
|
+
schedulerName: job.name,
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
/* best-effort provenance tracking */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const updatedJob = crewScheduler?.list().find((j) => j.id === job.id);
|
|
447
|
+
if (updatedJob) persistScheduledJobUpdate(extensionCtx.cwd, updatedJob);
|
|
448
|
+
} catch {
|
|
449
|
+
/* best-effort */
|
|
450
|
+
}
|
|
451
|
+
crewScheduler?.update(job.id, {
|
|
452
|
+
runCount: job.runCount + 1,
|
|
453
|
+
lastRun: new Date().toISOString(),
|
|
454
|
+
lastStatus: "success",
|
|
455
|
+
});
|
|
456
|
+
} catch (err) {
|
|
457
|
+
logInternalError("scheduler.execute", err);
|
|
458
|
+
crewScheduler?.update(job.id, { lastStatus: "error" });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
return agentId;
|
|
462
|
+
},
|
|
463
|
+
finalizer: () => {},
|
|
464
|
+
runCancelFn: (runId: string) => {
|
|
465
|
+
try {
|
|
466
|
+
handleTeamTool({ action: "cancel", runId, confirm: true }, { cwd: extensionCtx.cwd, sessionId }).catch((err) =>
|
|
467
|
+
logInternalError("scheduler.runCancelFn", err, `runId=${runId}`),
|
|
468
|
+
);
|
|
469
|
+
} catch (err) {
|
|
470
|
+
logInternalError("scheduler.runCancelFn.sync", err, `runId=${runId}`);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
return crewScheduler;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Build the render scheduler + preload loop + bounded run watchers.
|
|
479
|
+
*
|
|
480
|
+
* Render path:
|
|
481
|
+
* - RenderScheduler fires renderTick() every `effectiveRefreshMs()`.
|
|
482
|
+
* - 160ms when live agents OR background runs are active (spinner-friendly),
|
|
483
|
+
* else the configured `dashboardLiveRefreshMs` (default DEFAULT_UI.refreshMs).
|
|
484
|
+
* - renderTick reads from a pre-computed frame (`lastPreloadedManifests`) —
|
|
485
|
+
* zero fs I/O on the hot path.
|
|
486
|
+
*
|
|
487
|
+
* Watchers:
|
|
488
|
+
* - pts/2 hang fix (2026-06-16): a SINGLE non-recursive watcher on the
|
|
489
|
+
* `runs/` root (new-run detection) plus per-active-run watchers
|
|
490
|
+
* reconciled each preload tick. Total inotify cost: O(active runs).
|
|
491
|
+
*/
|
|
492
|
+
function setupRenderLoop(
|
|
493
|
+
pi: ExtensionAPI,
|
|
494
|
+
ctx: RegistrationContext,
|
|
495
|
+
extensionCtx: ExtensionContext,
|
|
496
|
+
loadedConfig: ReturnType<typeof loadConfig>,
|
|
497
|
+
): void {
|
|
498
|
+
ctx.disposeRenderSchedulerSubscriptions();
|
|
499
|
+
ctx.renderScheduler?.dispose();
|
|
500
|
+
ctx.terminalStatus?.dispose();
|
|
501
|
+
ctx.terminalStatus = undefined;
|
|
502
|
+
ctx.terminalStatusActive = false;
|
|
503
|
+
|
|
504
|
+
// Phase 12: Async preloading — renderTick reads only a pre-computed frame.
|
|
505
|
+
let preloading = false;
|
|
506
|
+
let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
|
|
507
|
+
let lastPreloadedManifests: TeamRunManifest[] = [];
|
|
508
|
+
let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
|
|
509
|
+
let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
|
|
510
|
+
|
|
511
|
+
const ownerGeneration = ctx.sessionGeneration;
|
|
512
|
+
|
|
513
|
+
const buildFrame = async (): Promise<boolean> => {
|
|
514
|
+
if (!ctx.currentCtx) return false;
|
|
515
|
+
lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
|
|
516
|
+
lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
|
|
517
|
+
lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
|
|
518
|
+
const manifests = lastFrameManifestCache.list(20);
|
|
519
|
+
lastPreloadedManifests = manifests;
|
|
520
|
+
// pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
|
|
521
|
+
{
|
|
522
|
+
const onRunChange = (runId: string): void => {
|
|
523
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
|
|
524
|
+
ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
|
|
525
|
+
ctx.renderScheduler?.schedule({ runId });
|
|
526
|
+
};
|
|
527
|
+
const onWatchErr = (error: unknown): void => {
|
|
528
|
+
logInternalError("register.runWatcher.change", error);
|
|
529
|
+
};
|
|
530
|
+
const active = manifests
|
|
531
|
+
.filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
|
|
532
|
+
.map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
|
|
533
|
+
ctx.crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
|
|
534
|
+
ctx.userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
|
|
535
|
+
}
|
|
536
|
+
const runIds = manifests.map((r) => r.runId);
|
|
537
|
+
await lastFrameSnapshotCache.preloadAllStale(runIds);
|
|
538
|
+
return true;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const backgroundPreload = (): void => {
|
|
542
|
+
if (!ctx.currentCtx || preloading) return;
|
|
543
|
+
preloading = true;
|
|
544
|
+
buildFrame()
|
|
545
|
+
.then((ok) => {
|
|
546
|
+
preloading = false;
|
|
547
|
+
if (ok) ctx.renderScheduler?.schedule();
|
|
548
|
+
})
|
|
549
|
+
.catch((error: unknown) => {
|
|
550
|
+
preloading = false;
|
|
551
|
+
logInternalError("register.backgroundPreload", error);
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
|
|
556
|
+
if (ctx.preloadTimer) clearTimeout(ctx.preloadTimer);
|
|
557
|
+
const tick = (): void => {
|
|
558
|
+
backgroundPreload();
|
|
559
|
+
const nextMs = dynamicMs?.() ?? intervalMs;
|
|
560
|
+
ctx.preloadTimer = setTimeout(tick, nextMs);
|
|
561
|
+
ctx.preloadTimer.unref();
|
|
562
|
+
};
|
|
563
|
+
ctx.preloadTimer = setTimeout(tick, intervalMs);
|
|
564
|
+
ctx.preloadTimer.unref();
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const renderTick = (): void => {
|
|
568
|
+
if (!ctx.currentCtx) return;
|
|
569
|
+
const config = lastPreloadedConfig?.config.ui;
|
|
570
|
+
const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
|
|
571
|
+
const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
|
|
572
|
+
const manifests = lastPreloadedManifests;
|
|
573
|
+
if (!lastPreloadedConfig) backgroundPreload();
|
|
574
|
+
if (ctx.uiState.liveSidebarRunId) {
|
|
575
|
+
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
576
|
+
if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
|
|
577
|
+
setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
|
|
578
|
+
setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
|
|
579
|
+
ctx.widgetState.lastVisibility = "hidden";
|
|
580
|
+
ctx.widgetState.lastPlacement = placement;
|
|
581
|
+
ctx.widgetState.lastKey = "pi-crew-active";
|
|
582
|
+
ctx.widgetState.model = undefined;
|
|
583
|
+
}
|
|
584
|
+
requestRender(ctx.currentCtx);
|
|
585
|
+
} else {
|
|
586
|
+
updateCrewWidget(ctx.currentCtx, ctx.widgetState, config, activeCache, snapshotCache, manifests);
|
|
587
|
+
}
|
|
588
|
+
requestPowerbarUpdate(
|
|
589
|
+
pi.events,
|
|
590
|
+
ctx.currentCtx.cwd,
|
|
591
|
+
config,
|
|
592
|
+
activeCache,
|
|
593
|
+
snapshotCache,
|
|
594
|
+
ctx.currentCtx,
|
|
595
|
+
ctx.widgetState.notificationCount ?? 0,
|
|
596
|
+
manifests,
|
|
597
|
+
);
|
|
598
|
+
// Health notifications: only warn about genuinely running runs.
|
|
599
|
+
const currentSessionGen = ctx.sessionGeneration;
|
|
600
|
+
const currentSessionId = ctx.currentCtx ? (ctx.currentCtx as { sessionId?: string }).sessionId : undefined;
|
|
601
|
+
const sessionManifests = manifests.filter(
|
|
602
|
+
(run) =>
|
|
603
|
+
!run.ownerSessionId ||
|
|
604
|
+
run.ownerSessionId === currentSessionId ||
|
|
605
|
+
(run as unknown as Record<string, unknown>).ownerSessionGeneration === currentSessionGen,
|
|
606
|
+
);
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
for (const run of sessionManifests) {
|
|
609
|
+
if (run.status !== "running") continue;
|
|
610
|
+
try {
|
|
611
|
+
const snapshot = snapshotCache.get(run.runId);
|
|
612
|
+
if (!snapshot) continue;
|
|
613
|
+
if (snapshot.manifest.status !== "running") continue;
|
|
614
|
+
const summary = summarizeHeartbeats(snapshot, { now });
|
|
615
|
+
const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
|
|
616
|
+
if (count <= 0) return;
|
|
617
|
+
const key = `${kind}_${run.runId}`;
|
|
618
|
+
const previous = ctx.autoRecoveryLast.get(key);
|
|
619
|
+
if (previous !== undefined && now - previous.lastAccessAt < 5 * 60_000) return;
|
|
620
|
+
// Defensive cap: evict entry with oldest lastAccessAt before inserting/updating.
|
|
621
|
+
while (ctx.autoRecoveryLast.size >= ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES) {
|
|
622
|
+
let oldestKey: string | undefined;
|
|
623
|
+
let oldestAccess = Infinity;
|
|
624
|
+
for (const [k, v] of ctx.autoRecoveryLast) {
|
|
625
|
+
if (v.lastAccessAt < oldestAccess) {
|
|
626
|
+
oldestAccess = v.lastAccessAt;
|
|
627
|
+
oldestKey = k;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (oldestKey === undefined) break;
|
|
631
|
+
ctx.autoRecoveryLast.delete(oldestKey);
|
|
632
|
+
}
|
|
633
|
+
ctx.autoRecoveryLast.set(key, {
|
|
634
|
+
insertedAt: now,
|
|
635
|
+
lastAccessAt: now,
|
|
636
|
+
});
|
|
637
|
+
ctx.notifyOperator({
|
|
638
|
+
id: key,
|
|
639
|
+
severity: "warning",
|
|
640
|
+
source: "health",
|
|
641
|
+
runId: run.runId,
|
|
642
|
+
title,
|
|
643
|
+
body,
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
maybeNotifyHealth(
|
|
647
|
+
"recovery_dead_workers",
|
|
648
|
+
summary.dead,
|
|
649
|
+
`Run ${run.runId} has ${summary.dead} dead worker(s).`,
|
|
650
|
+
"Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
|
|
651
|
+
);
|
|
652
|
+
maybeNotifyHealth(
|
|
653
|
+
"recovery_missing_heartbeat",
|
|
654
|
+
summary.missing,
|
|
655
|
+
`Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
|
|
656
|
+
"Open /team-dashboard → 5 health → inspect health actions.",
|
|
657
|
+
);
|
|
658
|
+
} catch (error) {
|
|
659
|
+
logInternalError("register.health-notification", error, run.runId);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
|
|
665
|
+
const liveRefreshMs = 160;
|
|
666
|
+
const hasActiveWork = (): boolean => {
|
|
667
|
+
if (listLiveAgents().some((a) => a.status === "running")) return true;
|
|
668
|
+
return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
|
|
669
|
+
};
|
|
670
|
+
const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
|
|
671
|
+
ctx.renderScheduler = new RenderScheduler(pi.events, renderTick, {
|
|
672
|
+
fallbackMs: effectiveRefreshMs,
|
|
673
|
+
onInvalidate: (payload: unknown) => {
|
|
674
|
+
const runId =
|
|
675
|
+
typeof payload === "object" &&
|
|
676
|
+
payload !== null &&
|
|
677
|
+
"runId" in payload &&
|
|
678
|
+
typeof (payload as { runId: unknown }).runId === "string"
|
|
679
|
+
? (payload as { runId: string }).runId
|
|
680
|
+
: undefined;
|
|
681
|
+
ctx.getRunSnapshotCache(extensionCtx.cwd).invalidate(runId);
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
// Fix D: bridge internal runEventBus events to renderScheduler so the UI
|
|
685
|
+
// re-renders within debounceMs of any agent lifecycle event.
|
|
686
|
+
const sched = ctx.renderScheduler;
|
|
687
|
+
const unsubscribeRunEvents = runEventBus.onAny((event) => {
|
|
688
|
+
sched.schedule({
|
|
689
|
+
runId: event.runId,
|
|
690
|
+
source: "runEventBus",
|
|
691
|
+
type: event.type,
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
ctx.renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
|
|
695
|
+
startPreloadLoop(fallbackMs, effectiveRefreshMs);
|
|
696
|
+
|
|
697
|
+
// Bounded run watcher setup (pts/2 hang fix 2026-06-16).
|
|
698
|
+
const crewRunWatcherOnChange = (runId: string): void => {
|
|
699
|
+
if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
|
|
700
|
+
ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
|
|
701
|
+
ctx.renderScheduler?.schedule({ runId });
|
|
702
|
+
};
|
|
703
|
+
const crewRunWatcherOnError = (error: unknown): void => {
|
|
704
|
+
logInternalError("register.crewRunWatchers.error", error);
|
|
705
|
+
};
|
|
706
|
+
try {
|
|
707
|
+
ctx.crewRunWatchers?.closeAll();
|
|
708
|
+
ctx.crewRunWatchers = undefined;
|
|
709
|
+
const crewRunsDir = path.join(projectCrewRoot(extensionCtx.cwd), "state", "runs");
|
|
710
|
+
if (fs.existsSync(crewRunsDir)) {
|
|
711
|
+
ctx.crewRunWatchers = new RunWatcherRegistry();
|
|
712
|
+
ctx.crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
|
|
713
|
+
}
|
|
714
|
+
} catch (error) {
|
|
715
|
+
logInternalError("register.crewRunWatchers.start", error);
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
ctx.userCrewWatchers?.closeAll();
|
|
719
|
+
ctx.userCrewWatchers = undefined;
|
|
720
|
+
const userRunsDir = path.join(userCrewRoot(), "state", "runs");
|
|
721
|
+
if (fs.existsSync(userRunsDir)) {
|
|
722
|
+
ctx.userCrewWatchers = new RunWatcherRegistry();
|
|
723
|
+
ctx.userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
|
|
724
|
+
}
|
|
725
|
+
} catch (error) {
|
|
726
|
+
logInternalError("register.userCrewWatchers.start", error);
|
|
727
|
+
}
|
|
728
|
+
// Kick an immediate preload so the first buildFrame reconciles per-run
|
|
729
|
+
// watchers for any runs that are already active on session start.
|
|
730
|
+
backgroundPreload();
|
|
731
|
+
}
|