pi-crew 0.11.7 → 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.
Files changed (45) hide show
  1. package/.githooks/pre-commit +22 -0
  2. package/.githooks/pre-push +56 -0
  3. package/CHANGELOG.md +121 -1
  4. package/dist/index.mjs +528 -510
  5. package/docs/publishing.md +29 -0
  6. package/index.ts +3 -3
  7. package/package.json +8 -5
  8. package/scripts/install-hooks.mjs +32 -0
  9. package/skills/real-test-pi-crew/SKILL.md +31 -18
  10. package/src/config/config-validation.ts +8 -1
  11. package/src/config/env-vars.ts +16 -1
  12. package/src/config/types.ts +28 -4
  13. package/src/extension/registration/foreground-run-controller.ts +19 -10
  14. package/src/extension/registration/lifecycle-handlers.ts +6 -491
  15. package/src/extension/registration/lifecycle.ts +7 -90
  16. package/src/extension/registration/render-loop.ts +502 -0
  17. package/src/extension/team-tool/doctor.ts +6 -0
  18. package/src/runtime/child-pi/child-pi-spawn.ts +7 -0
  19. package/src/runtime/child-pi/child-pi.ts +37 -2
  20. package/src/runtime/child-pi/pi-version.ts +187 -0
  21. package/src/runtime/classifier/classifier-service.ts +298 -0
  22. package/src/runtime/dispatch-batch.ts +4 -0
  23. package/src/runtime/live-session/live-session-runtime.ts +31 -0
  24. package/src/runtime/model/pi-args.ts +62 -3
  25. package/src/runtime/process/zombie-scanner.ts +168 -7
  26. package/src/runtime/rpc/README.md +64 -25
  27. package/src/runtime/rpc/rpc-worker.ts +146 -26
  28. package/src/runtime/run-worker.ts +89 -15
  29. package/src/runtime/scheduling/run-coalesced-task-group.ts +3 -0
  30. package/src/runtime/skill-instructions.ts +66 -1
  31. package/src/runtime/surface/resolve-surface.ts +34 -19
  32. package/src/runtime/task-runner/child-executor.ts +132 -0
  33. package/src/runtime/task-runner/pre-execution.ts +8 -0
  34. package/src/runtime/task-runner.ts +6 -0
  35. package/src/schema/config-schema.ts +20 -8
  36. package/src/schema/team-tool-schema.ts +4 -2
  37. package/src/state/contracts.ts +5 -0
  38. package/src/ui/format-helpers.ts +11 -0
  39. package/src/ui/overlays/mailbox-compose-overlay.ts +15 -1
  40. package/src/ui/rail.ts +14 -1
  41. package/src/ui/settings-overlay.ts +11 -2
  42. package/src/ui/widget/task-list.ts +10 -3
  43. package/src/ui/widget/widget-renderer.ts +53 -4
  44. package/src/utils/relative-time.ts +11 -3
  45. package/src/utils/visual.ts +32 -2
@@ -2,9 +2,10 @@
2
2
  * Lifecycle handler installer for pi-crew.
3
3
  *
4
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.
5
+ * • `session_start` — heavy setup (config, scheduler, deferred crash
6
+ * recovery). This is the bulk of the orchestrator's per-session work,
7
+ * extracted here so `register.ts` stays thin. The render loop + preload
8
+ * pipeline + bounded run watchers live in ./render-loop.ts (QW#2).
8
9
  * • `session_shutdown` — reason-aware cleanup (quit/reload aborts
9
10
  * foreground runs; resume/new/fork preserves them).
10
11
  * • `session_before_switch` — graceful session switch handoff.
@@ -14,8 +15,6 @@
14
15
  * deferring it. The session_start handler internally uses lazy imports
15
16
  * for its own per-call optional work (foreground-watchdog, atomic-write).
16
17
  */
17
- import * as fs from "node:fs";
18
- import * as path from "node:path";
19
18
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
19
  import { loadConfig } from "../../config/config.ts";
21
20
  import { DEFAULT_UI } from "../../config/defaults.ts";
@@ -29,8 +28,6 @@ import {
29
28
  import { type BrokerSpawnCredentials, setActiveBrokerIssuer, setActiveBrokerRevoker } from "../../runtime/broker/broker-issuer.ts";
30
29
  import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
31
30
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
32
- import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } from "../../runtime/detached-run-results.ts";
33
- import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
34
31
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
35
32
  import { configuredModelInfosFromPiConfig } from "../../runtime/model/model-fallback.ts";
36
33
  import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth, resolveCrewMaxDepth } from "../../runtime/model/pi-args.ts";
@@ -43,26 +40,12 @@ import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
43
40
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
44
41
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
45
42
  import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
46
- import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
47
43
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
48
- import type { TeamRunManifest } from "../../state/types.ts";
49
- import { overlayFreshTaskStatuses, summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
50
44
  import { installInlinePanel } from "../../ui/inline-panel/index.ts";
51
45
  import { clearSessionSwitchInFlight, markSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
52
- import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
53
- import {
54
- registerPiCrewPowerbarSegments,
55
- requestPowerbarUpdate,
56
- resetPowerbarDedupState,
57
- updatePiCrewPowerbar,
58
- } from "../../ui/powerbar-publisher.ts";
59
- import { RenderScheduler } from "../../ui/render-scheduler.ts";
60
- import { runEventBus } from "../../ui/run-event-bus.ts";
61
- import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
46
+ import { registerPiCrewPowerbarSegments, resetPowerbarDedupState, updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
62
47
  import { updateCrewWidget } from "../../ui/widget/index.ts";
63
48
  import { logInternalError } from "../../utils/internal-error.ts";
64
- import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
65
- import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
66
49
  import { extractBrokerSessionId } from "../../utils/session-utils.ts";
67
50
  import { getBrokerSocketPath } from "../../utils/socket-path.ts";
68
51
  import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
@@ -71,10 +54,9 @@ import { notifyActiveRuns } from "../session-summary.ts";
71
54
  import { persistScheduledJobUpdate, registerCrewScheduler, stashScheduledJobsHiddenCount } from "../team-tool/handle-schedule.ts";
72
55
  import { handleTeamTool } from "../team-tool.ts";
73
56
  import { runArtifactCleanup } from "./artifact-cleanup.ts";
74
- import { healthNotifyFingerprint, recordHealthNotifyDecision, resetHealthNotifyEntry } from "./health-notify-policy.ts";
75
57
  import type { RegistrationContext } from "./registration-types.ts";
58
+ import { deliverDetachedRunResults, setupRenderLoop } from "./render-loop.ts";
76
59
  import { createScheduleEventNotifier } from "./schedule-toast-bridge.ts";
77
- import { purgeQueuedAmbientNotifications } from "./subagent-helpers.ts";
78
60
  import { refreshCrossExtensionWiringForSession } from "./wire-cross-extension.ts";
79
61
 
80
62
  /**
@@ -145,36 +127,6 @@ export function __test__resetNotifiedRepairedRunIds(): void {
145
127
  notifiedRepairedRunIds.clear();
146
128
  }
147
129
 
148
- /**
149
- * Hand over the outcome of runs detached by an agent-view switch.
150
- *
151
- * Delivered as a DISPLAYED session entry (not a follow-up queue item): an idle
152
- * session never flushes a follow-up, so the queued variant left the result
153
- * invisible. Each result is dropped from the registry only after its send
154
- * succeeded, and only while the owning session is current — a worker's view
155
- * session must never receive the parent run's report.
156
- */
157
- function deliverDetachedRunResults(pi: ExtensionAPI, extensionCtx: ExtensionContext): void {
158
- if (!hasDetachedRuns()) return;
159
- try {
160
- // Agent views are in-document panes now, never sessions: the current
161
- // session is always the main one, so detached results always deliver.
162
- const inViewSession = false;
163
- for (const { runId, text } of peekFinishedDetachedRunResults({ inViewSession })) {
164
- pi.sendMessage({ customType: "pi-crew-run-result", content: text, display: true });
165
- forgetDetachedRun(runId);
166
- try {
167
- extensionCtx.ui.notify(text.split("\n")[0] ?? `pi-crew run finished: ${runId}`, "info");
168
- } catch {
169
- /* toast is secondary to the session entry */
170
- }
171
- }
172
- } catch (error) {
173
- // Keep the entry: the next tick retries rather than losing the outcome.
174
- logInternalError("register.detachedRunResults", error);
175
- }
176
- }
177
-
178
130
  /**
179
131
  * model_select / thinking_level_select:
180
132
  * Track what the MAIN session is *actually* running so subagents that
@@ -681,443 +633,6 @@ function setupCrewScheduler(
681
633
  return crewScheduler;
682
634
  }
683
635
 
684
- /**
685
- * Build the render scheduler + preload loop + bounded run watchers.
686
- *
687
- * Render path:
688
- * - RenderScheduler fires renderTick() every `effectiveRefreshMs()`.
689
- * - 160ms when live agents OR background runs are active (spinner-friendly),
690
- * else the configured `dashboardLiveRefreshMs` (default DEFAULT_UI.refreshMs).
691
- * - renderTick reads from a pre-computed frame (`lastPreloadedManifests`) —
692
- * zero fs I/O on the hot path.
693
- *
694
- * Watchers:
695
- * - pts/2 hang fix (2026-06-16): a SINGLE non-recursive watcher on the
696
- * `runs/` root (new-run detection) plus per-active-run watchers
697
- * reconciled each preload tick. Total inotify cost: O(active runs).
698
- */
699
- /**
700
- * Phase 5 (Vector #3): keep only the CURRENT session's owned runs (plus
701
- * ownerless runs) for health notifications. Previously the inline filter derived
702
- * `currentSessionId` from a cast that was always `undefined` and compared
703
- * against `ownerSessionGeneration` (a field absent from TeamRunManifest), so
704
- * together they dropped EVERY owned run. Exported for unit testing.
705
- */
706
- export function filterManifestsForHealthNotifications(
707
- manifests: TeamRunManifest[],
708
- currentSessionId: string | undefined,
709
- ): TeamRunManifest[] {
710
- return manifests.filter((run) => !run.ownerSessionId || run.ownerSessionId === currentSessionId);
711
- }
712
-
713
- /**
714
- * bug-026 sub-issue C: runEventBus event types that mark a run TERMINAL,
715
- * across BOTH type namespaces seen on the bus — dotted `run.*` strings (kept
716
- * for parity with classifyEventChannel's WORKER_LIFECYCLE_TYPES, see
717
- * src/ui/run-event-bus.ts:31-44) and underscore `run_*` (the native
718
- * RunEventType namespace actually emitted by team-runner, e.g.
719
- * `run_completed`). Exported for unit testing.
720
- */
721
- export const TERMINAL_RUN_EVENT_TYPES: ReadonlySet<string> = new Set([
722
- "run.completed",
723
- "run.failed",
724
- "run.cancelled",
725
- "run_completed",
726
- "run_failed",
727
- "run_cancelled",
728
- ]);
729
-
730
- /** True when a runEventBus event type marks the run terminal (either namespace). */
731
- export function isTerminalRunEventType(type: string): boolean {
732
- return TERMINAL_RUN_EVENT_TYPES.has(type);
733
- }
734
-
735
- /** Pure filter: drop `runId` from a preloaded-manifest frame (bug-026 sub-issue C eviction). */
736
- export function evictRunFromManifests(manifests: TeamRunManifest[], runId: string): TeamRunManifest[] {
737
- return manifests.filter((m) => m.runId !== runId);
738
- }
739
-
740
- /**
741
- * F14 (RR-019): cheap signature of the preloaded-manifest frame. Only a
742
- * CHANGED signature lets `backgroundPreload` count as render activity
743
- * (`schedule()`); an identical signature means "nothing the widget would
744
- * paint differently" and must NOT reset the render scheduler's idle
745
- * counters (that defeated the R1 idle stop — see setupRenderLoop). Covers
746
- * run identity, status transitions, and updatedAt bumps. Exported for tests.
747
- */
748
- export function manifestsFrameSignature(manifests: TeamRunManifest[]): string {
749
- return manifests.map((m) => `${m.runId}:${m.status}:${m.updatedAt}`).join("|");
750
- }
751
-
752
- /**
753
- * Apply a runEventBus payload to a preloaded-manifest frame: evict the run on
754
- * terminal events, pass through unchanged otherwise. This is the exact logic
755
- * wired into the setupRenderLoop `runEventBus.onAny` subscription (bug-026
756
- * sub-issue C); exported so tests can exercise it end-to-end against the
757
- * real bus without spinning up the full render loop.
758
- */
759
- export function applyTerminalRunEventToManifests(manifests: TeamRunManifest[], event: { type: string; runId: string }): TeamRunManifest[] {
760
- return isTerminalRunEventType(event.type) ? evictRunFromManifests(manifests, event.runId) : manifests;
761
- }
762
-
763
- function setupRenderLoop(
764
- pi: ExtensionAPI,
765
- ctx: RegistrationContext,
766
- extensionCtx: ExtensionContext,
767
- loadedConfig: ReturnType<typeof loadConfig>,
768
- ): void {
769
- ctx.disposeRenderSchedulerSubscriptions();
770
- ctx.renderScheduler?.dispose();
771
- ctx.terminalStatus?.dispose();
772
- ctx.terminalStatus = undefined;
773
- ctx.terminalStatusActive = false;
774
-
775
- // Phase 12: Async preloading — renderTick reads only a pre-computed frame.
776
- let preloading = false;
777
- let lastPreloadedConfig: ReturnType<typeof loadConfig> | undefined;
778
- let lastPreloadedManifests: TeamRunManifest[] = [];
779
- let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
780
- let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
781
- // F14: signature of the last frame backgroundPreload saw — only a change
782
- // here counts as render activity.
783
- let lastFrameSignature: string | undefined;
784
-
785
- const ownerGeneration = ctx.sessionGeneration;
786
-
787
- const buildFrame = async (): Promise<{ ok: boolean; changed: boolean }> => {
788
- if (!ctx.currentCtx) return { ok: false, changed: false };
789
- lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
790
- lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
791
- lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
792
- const manifests = lastFrameManifestCache.list(20);
793
- lastPreloadedManifests = manifests;
794
- // F14 (RR-019): compute the frame signature BEFORE the preload await so
795
- // `changed` reflects what this tick saw, not a mid-preload mutation.
796
- const frameSignature = manifestsFrameSignature(manifests);
797
- const frameChanged = frameSignature !== lastFrameSignature;
798
- lastFrameSignature = frameSignature;
799
- // pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
800
- {
801
- const onRunChange = (runId: string): void => {
802
- if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
803
- // FLICKER FIX: rebuild-in-place instead of deleting the entry. The
804
- // file just changed on disk, so force a fresh snapshot while keeping
805
- // the entry populated — deleting it left a window where the widget's
806
- // `get()` returned undefined and dropped the run to "(loading…)".
807
- // PERF (2026-08-24): route through the coalesced ASYNC refresh —
808
- // fs.watch can fire many times per second and the sync rebuild
809
- // blocked the UI event loop. The entry stays populated until the
810
- // async rebuild re-sets it in place; the render schedule below
811
- // repaints while it lands.
812
- try {
813
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
814
- } catch (error) {
815
- logInternalError("register.runWatcher.refresh", error, runId);
816
- }
817
- ctx.renderScheduler?.schedule({ runId });
818
- };
819
- const onWatchErr = (error: unknown): void => {
820
- logInternalError("register.runWatcher.change", error);
821
- };
822
- const active = manifests
823
- .filter((r) => r.status === "running" || r.status === "queued" || r.status === "planning")
824
- .map((r) => ({ runId: r.runId, runDir: r.stateRoot }));
825
- ctx.crewRunWatchers?.reconcile(active, onRunChange, onWatchErr);
826
- ctx.userCrewWatchers?.reconcile(active, onRunChange, onWatchErr);
827
- }
828
- const runIds = manifests.map((r) => r.runId);
829
- await lastFrameSnapshotCache.preloadAllStale(runIds);
830
- return { ok: true, changed: frameChanged };
831
- };
832
-
833
- const backgroundPreload = (): void => {
834
- if (!ctx.currentCtx || preloading) return;
835
- preloading = true;
836
- buildFrame()
837
- .then(({ ok, changed }) => {
838
- preloading = false;
839
- // F14 (RR-019): preload completion is maintenance, NOT user activity.
840
- // Only a genuinely CHANGED frame may call schedule() — an unchanged
841
- // frame calling schedule() reset `lastEventAt`/`idleFallbackRenders`
842
- // and re-armed the fallback loop that fallbackLoop() had just
843
- // deliberately stopped (R1), rendering forever while idle. Real
844
- // work still animates: runEventBus + fs.watch subscriptions call
845
- // schedule() directly on genuine events.
846
- if (ok && changed) ctx.renderScheduler?.schedule();
847
- })
848
- .catch((error: unknown) => {
849
- preloading = false;
850
- logInternalError("register.backgroundPreload", error);
851
- });
852
- };
853
-
854
- const startPreloadLoop = (intervalMs: number, dynamicMs?: () => number): void => {
855
- if (ctx.preloadTimer) clearTimeout(ctx.preloadTimer);
856
- const tick = (): void => {
857
- backgroundPreload();
858
- const nextMs = dynamicMs?.() ?? intervalMs;
859
- ctx.preloadTimer = setTimeout(tick, nextMs);
860
- ctx.preloadTimer.unref();
861
- };
862
- ctx.preloadTimer = setTimeout(tick, intervalMs);
863
- ctx.preloadTimer.unref();
864
- };
865
-
866
- const renderTick = (): void => {
867
- if (!ctx.currentCtx) return;
868
- deliverDetachedRunResults(pi, ctx.currentCtx);
869
- const config = lastPreloadedConfig?.config.ui;
870
- const activeCache = lastFrameManifestCache ?? ctx.getManifestCache(ctx.currentCtx.cwd);
871
- const snapshotCache = lastFrameSnapshotCache ?? ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
872
- const manifests = lastPreloadedManifests;
873
- if (!lastPreloadedConfig) backgroundPreload();
874
- if (ctx.uiState.liveSidebarRunId || ctx.uiState.dashboardOpen) {
875
- const placement = toPiWidgetPlacement(config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement);
876
- if (ctx.widgetState.lastVisibility !== "hidden" || ctx.widgetState.lastPlacement !== placement) {
877
- setExtensionWidget(ctx.currentCtx, "pi-crew", undefined, { placement });
878
- setExtensionWidget(ctx.currentCtx, "pi-crew-active", undefined, { placement });
879
- ctx.widgetState.lastVisibility = "hidden";
880
- ctx.widgetState.lastPlacement = placement;
881
- ctx.widgetState.lastKey = "pi-crew-active";
882
- ctx.widgetState.model = undefined;
883
- }
884
- requestRender(ctx.currentCtx);
885
- } else {
886
- updateCrewWidget(ctx.currentCtx, ctx.widgetState, config, activeCache, snapshotCache, manifests);
887
- }
888
- requestPowerbarUpdate(
889
- pi.events,
890
- ctx.currentCtx.cwd,
891
- config,
892
- activeCache,
893
- snapshotCache,
894
- ctx.currentCtx,
895
- ctx.widgetState.notificationCount ?? 0,
896
- manifests,
897
- );
898
- // Health notifications: only warn about genuinely running runs.
899
- // Phase 5 (Vector #3): derive currentSessionId via the working accessor.
900
- // ctx is RegistrationContext; currentCtx holds the ExtensionContext whose
901
- // sessionManager exposes getSessionId(). The previous cast to {sessionId?}
902
- // was always undefined, and the ownerSessionGeneration clause referenced a
903
- // field absent from TeamRunManifest — together they dropped EVERY owned
904
- // run. Now only the current session's owned runs + ownerless runs pass.
905
- const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
906
- const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
907
- const now = Date.now();
908
- // FIX #2: clear path — when a run is detected terminal, dismiss any
909
- // previously-emitted health notification for it AND drop its cooldown
910
- // (autoRecoveryLast) so a future genuine re-occurrence can re-notify.
911
- // Keeps the dashboard clean and stops the 5-min re-fire cycle.
912
- const clearHealthNotifications = (runId: string): void => {
913
- for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
914
- const key = `${kind}_${runId}`;
915
- // FINDING 6: reset the fire budget (a genuine recurrence after
916
- // this clear re-notifies) AND opportunistically purge
917
- // still-QUEUED host follow-up copies of the original warning —
918
- // the host drains the queue one message per turn boundary, so
919
- // stale copies otherwise drip in for hours after the clear
920
- // (live: 5h of "missing heartbeat" replays after the 2026-09-23
921
- // zombie cleanup). The purge is feature-detected: hosts without
922
- // clearQueuedUserMessagesMatching (e.g. 0.87.0) no-op here; the
923
- // fire-cap policy bounds the backlog on every host.
924
- resetHealthNotifyEntry({ entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES }, key);
925
- ctx.notifyOperator({
926
- id: key,
927
- clear: true,
928
- severity: "info",
929
- source: "health",
930
- runId,
931
- title: `Cleared ${kind} for ${runId}`,
932
- });
933
- purgeQueuedAmbientNotifications(
934
- pi,
935
- (text) => text.includes(runId) && (text.includes("dead worker") || text.includes("missing heartbeat")),
936
- );
937
- }
938
- };
939
- for (const run of sessionManifests) {
940
- if (run.status !== "running") {
941
- // GATE 1 — preloaded manifest says terminal. Purge any stale snapshot
942
- // and clear previously-emitted health notifications so the dashboard
943
- // stays clean (belt-and-suspenders with the FIX #1 fresh-read gate).
944
- snapshotCache.invalidate(run.runId);
945
- clearHealthNotifications(run.runId);
946
- continue;
947
- }
948
- try {
949
- // FIX #1: re-verify against a FRESH manifest read. The preloaded `run`
950
- // (from lastPreloadedManifests) can lag the on-disk terminal
951
- // transition; the manifest cache has a 500ms TTL + file watcher so it
952
- // is the source of truth. A terminal run must NEVER reach
953
- // maybeNotifyHealth. Also purge the stale snapshot + clear any
954
- // previously-emitted health notification for this run.
955
- const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
956
- if (freshManifest?.status !== "running") {
957
- snapshotCache.invalidate(run.runId);
958
- clearHealthNotifications(run.runId);
959
- continue;
960
- }
961
- const snapshot = snapshotCache.get(run.runId);
962
- if (!snapshot) continue;
963
- if (snapshot.manifest.status !== "running") {
964
- // GATE 2 — a running snapshot paired with a now-terminal manifest is
965
- // stale. Purge it so subsequent ticks get a fresh view, and clear.
966
- snapshotCache.invalidate(run.runId);
967
- clearHealthNotifications(run.runId);
968
- continue;
969
- }
970
- // GATE 3 (FINDING 5) — task-status truth lives on DISK, not in the
971
- // snapshot cache. A worker parked on `ask` writes running → waiting
972
- // to tasks.json immediately, but the cached snapshot can lag that
973
- // transition; in the window a parked (alive, silent-by-design)
974
- // worker counted as active-without-heartbeat and fired a false
975
- // "dead worker" (live: team_20260923100114, 01_explore parked on
976
- // ask). Overlay FRESH statuses before summarizing; divergence also
977
- // invalidates the stale cache entry so the next tick rebuilds it.
978
- const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
979
- const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
980
- if (overlaid !== snapshot) snapshotCache.invalidate(run.runId);
981
- const summary = summarizeHeartbeats(overlaid, { now });
982
- const fingerprint = healthNotifyFingerprint(summary, overlaid.tasks.length);
983
- const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
984
- if (count <= 0) return;
985
- const key = `${kind}_${run.runId}`;
986
- // FINDING 6: bounded re-fire — ≤ MAX_HEALTH_NOTIFY_FIRES per
987
- // UNCHANGED fingerprint (5-min cooldown between fires). The
988
- // old forever-re-arming cooldown fed the host follow-up queue
989
- // duplicates that drained one-per-turn-boundary for hours.
990
- // Policy detail + LRU eviction: health-notify-policy.ts.
991
- const state = { entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES };
992
- if (!recordHealthNotifyDecision(state, key, fingerprint, now)) return;
993
- ctx.notifyOperator({
994
- id: key,
995
- severity: "warning",
996
- source: "health",
997
- runId: run.runId,
998
- title,
999
- body,
1000
- });
1001
- };
1002
- maybeNotifyHealth(
1003
- "recovery_dead_workers",
1004
- summary.dead,
1005
- `Run ${run.runId} has ${summary.dead} dead worker(s).`,
1006
- "Open /team-dashboard → 5 health → R recovery / K kill stale / D diagnostic.",
1007
- );
1008
- maybeNotifyHealth(
1009
- "recovery_missing_heartbeat",
1010
- summary.missing,
1011
- `Run ${run.runId} has ${summary.missing} worker(s) missing heartbeat.`,
1012
- "Open /team-dashboard → 5 health → inspect health actions.",
1013
- );
1014
- } catch (error) {
1015
- logInternalError("register.health-notification", error, run.runId);
1016
- }
1017
- }
1018
- };
1019
-
1020
- const fallbackMs = loadedConfig.config.ui?.dashboardLiveRefreshMs ?? DEFAULT_UI.refreshMs;
1021
- const liveRefreshMs = 160;
1022
- const hasActiveWork = (): boolean => {
1023
- if (listLiveAgents().some((a) => a.status === "running")) return true;
1024
- return lastPreloadedManifests.some((r) => r.status === "running" || r.status === "queued" || r.status === "planning");
1025
- };
1026
- const effectiveRefreshMs = () => (hasActiveWork() ? liveRefreshMs : fallbackMs);
1027
- ctx.renderScheduler = new RenderScheduler(pi.events, renderTick, {
1028
- fallbackMs: effectiveRefreshMs,
1029
- onInvalidate: (payload: unknown) => {
1030
- const runId =
1031
- typeof payload === "object" &&
1032
- payload !== null &&
1033
- "runId" in payload &&
1034
- typeof (payload as { runId: unknown }).runId === "string"
1035
- ? (payload as { runId: string }).runId
1036
- : undefined;
1037
- // FLICKER FIX: never hard-delete snapshot entries from a render-scheduler
1038
- // invalidate. A no-runId payload — emitted by EVERY fallback tick
1039
- // (~every 160ms while a run is active) — previously ran
1040
- // `invalidate(undefined)` → `entries.clear()`, wiping ALL snapshots.
1041
- // The next `renderTick` then saw `get() === undefined` for every run,
1042
- // so `activeWidgetRuns` dropped them to "(loading…)" until the async
1043
- // preload rebuilt the cache — an endless visible flicker. For a
1044
- // specific runId we now refresh-if-stale (stale-while-revalidate) so
1045
- // the widget always sees a populated snapshot; a no-runId tick does
1046
- // nothing (renderTick itself repaints; the cache's own
1047
- // run:state/worker:lifecycle subscription refreshes affected runs).
1048
- if (!runId) return;
1049
- try {
1050
- ctx.getRunSnapshotCache(extensionCtx.cwd).refreshIfStale(runId);
1051
- } catch (error) {
1052
- logInternalError("register.renderScheduler.refresh", error, runId);
1053
- }
1054
- },
1055
- });
1056
- // Fix D: bridge internal runEventBus events to renderScheduler so the UI
1057
- // re-renders within debounceMs of any agent lifecycle event.
1058
- const sched = ctx.renderScheduler;
1059
- const unsubscribeRunEvents = runEventBus.onAny((event) => {
1060
- // bug-026 sub-issue C: evict terminal runs from the preloaded manifest
1061
- // frame so a stale "running" entry cannot persist for the session
1062
- // lifetime. Additive to the renderTick GATE 1 / FIX #1 / GATE 2 snapshot
1063
- // purges (which invalidate the snapshot cache) — those gates do not
1064
- // touch lastPreloadedManifests itself.
1065
- lastPreloadedManifests = applyTerminalRunEventToManifests(lastPreloadedManifests, event);
1066
- sched.schedule({
1067
- runId: event.runId,
1068
- source: "runEventBus",
1069
- type: event.type,
1070
- });
1071
- });
1072
- ctx.renderSchedulerUnsubscribers.push(unsubscribeRunEvents);
1073
- startPreloadLoop(fallbackMs, effectiveRefreshMs);
1074
-
1075
- // Bounded run watcher setup (pts/2 hang fix 2026-06-16).
1076
- const crewRunWatcherOnChange = (runId: string): void => {
1077
- if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
1078
- // FLICKER FIX: rebuild-in-place instead of deleting the entry (see
1079
- // onRunChange above). A hard delete left `get()` returning undefined for
1080
- // a frame, dropping the run to "(loading…)" and causing visible flicker.
1081
- // PERF (2026-08-24): coalesced ASYNC refresh — fs.watch can fire many
1082
- // times per second and the sync rebuild blocked the UI event loop; the
1083
- // render schedule below repaints while the rebuild lands.
1084
- try {
1085
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).scheduleRefresh(runId);
1086
- } catch (error) {
1087
- logInternalError("register.crewRunWatcher.refresh", error, runId);
1088
- }
1089
- ctx.renderScheduler?.schedule({ runId });
1090
- };
1091
- const crewRunWatcherOnError = (error: unknown): void => {
1092
- logInternalError("register.crewRunWatchers.error", error);
1093
- };
1094
- try {
1095
- ctx.crewRunWatchers?.closeAll();
1096
- ctx.crewRunWatchers = undefined;
1097
- const crewRunsDir = path.join(projectCrewRoot(extensionCtx.cwd), "state", "runs");
1098
- if (fs.existsSync(crewRunsDir)) {
1099
- ctx.crewRunWatchers = new RunWatcherRegistry();
1100
- ctx.crewRunWatchers.setRootWatcher(crewRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
1101
- }
1102
- } catch (error) {
1103
- logInternalError("register.crewRunWatchers.start", error);
1104
- }
1105
- try {
1106
- ctx.userCrewWatchers?.closeAll();
1107
- ctx.userCrewWatchers = undefined;
1108
- const userRunsDir = path.join(userCrewRoot(), "state", "runs");
1109
- if (fs.existsSync(userRunsDir)) {
1110
- ctx.userCrewWatchers = new RunWatcherRegistry();
1111
- ctx.userCrewWatchers.setRootWatcher(userRunsDir, crewRunWatcherOnChange, crewRunWatcherOnError);
1112
- }
1113
- } catch (error) {
1114
- logInternalError("register.userCrewWatchers.start", error);
1115
- }
1116
- // Kick an immediate preload so the first buildFrame reconciles per-run
1117
- // watchers for any runs that are already active on session start.
1118
- backgroundPreload();
1119
- }
1120
-
1121
636
  // =============================================================================
1122
637
  // Phase 0 inter-pi broker lifecycle controller (sub-task 0.5)
1123
638
  // =============================================================================
@@ -5,10 +5,12 @@
5
5
  * active. This is the "lazy" half of H3 — the heavy heartbeat/auto-repair
6
6
  * graph stays out of cold start until a run actually starts.
7
7
  *
8
- * Extracted from src/extension/register.ts. State holder is `LifecycleState`;
9
- * installer is `startLifecycleWatchers(ctx, state, deps)` which fires on
10
- * session_start (when the first run becomes possible) and is no-op when
11
- * `activeRunCount === 0`.
8
+ * NOTE (GH #62 audit, 2026-10-05): the former startLifecycleWatchers /
9
+ * stopLifecycleWatchers pair was REMOVED — dead code (0 callers since the
10
+ * lifecycle-handlers.ts rewrite) carrying a duplicate async-notifier install
11
+ * gated on the object-identity isContextCurrent pattern that #62 proved
12
+ * fragile for tool-path contexts. The async-run notifier is installed
13
+ * directly in lifecycle-handlers.ts (session_start).
12
14
  *
13
15
  * Also owns DeliveryCoordinator + OverflowRecoveryTracker + NotificationRouter
14
16
  * wiring since these are all "delivery lifecycle" services that share the
@@ -16,13 +18,10 @@
16
18
  */
17
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
20
  import { loadConfig } from "../../config/config.ts";
19
- import { DEFAULT_NOTIFICATIONS, DEFAULT_UI } from "../../config/defaults.ts";
20
- import { logInternalError } from "../../utils/internal-error.ts";
21
+ import { DEFAULT_NOTIFICATIONS } from "../../config/defaults.ts";
21
22
  import { projectCrewRoot } from "../../utils/paths.ts";
22
- import type { AsyncNotifierState } from "../async-notifier.ts";
23
23
  import type { NotificationDescriptor, NotificationRouter } from "../notification-router.ts";
24
24
  import type { NotificationSink } from "../notification-sink.ts";
25
- import { createWebhookNotifier } from "../webhook-notify.ts";
26
25
 
27
26
  /**
28
27
  * Mutable state owned by register.ts and read/written by this module.
@@ -37,88 +36,6 @@ export interface LifecycleState {
37
36
  overflowTracker: import("../../runtime/recovery/overflow-recovery.ts").OverflowRecoveryTracker | undefined;
38
37
  }
39
38
 
40
- /** Dependencies passed in by register.ts so this module stays decoupled. */
41
- export interface LifecycleDeps {
42
- pi: ExtensionAPI;
43
- notifierState: AsyncNotifierState;
44
- isCleanedUp: () => boolean;
45
- isContextCurrent: (ctx: ExtensionContext, ownerGeneration: number) => boolean;
46
- ownerGeneration: number;
47
- }
48
-
49
- /**
50
- * Start the async-run notifier. Idempotent — repeated calls while running
51
- * are no-ops. Returns true if started now, false if already running or
52
- * skipped due to config gate.
53
- *
54
- * NOTE: This module does NOT start heartbeat watchers or auto-repair timers.
55
- * Those live in `observability.ts` (configurable + resource-heavy). This
56
- * module only manages the lightweight async-run poller.
57
- */
58
- export function startLifecycleWatchers(ctx: ExtensionContext, state: LifecycleState, deps: LifecycleDeps): boolean {
59
- if (state.notifierStarted) return false;
60
- const loadedConfig = loadConfig(ctx.cwd);
61
- state.notifierStarted = true;
62
- try {
63
- // US-030 (docs/specs/US-030.md): outbound webhook sink built from config.
64
- // Gated on the notifications master switch (`notifications.enabled !== false`,
65
- // same gate as configureNotifications), opt-in per `webhook.url`, and
66
- // SSRF-guarded inside the factory. Inactive config → shared no-op
67
- // singleton (zero network calls, zero hot-path allocation).
68
- const notificationsConfig = loadedConfig.config.notifications;
69
- const webhookNotifier =
70
- notificationsConfig?.enabled === false
71
- ? undefined
72
- : createWebhookNotifier(
73
- { webhook: notificationsConfig?.webhook, quietHours: notificationsConfig?.quietHours },
74
- {
75
- onFailure: (failure) => {
76
- // Spec: failures surface as a `webhook.failed` event (crew.*-prefixed
77
- // pi event, mirroring crew.run.completed / crew.task.overflow).
78
- // logInternalError already fired inside the notifier.
79
- deps.pi.events?.emit?.("crew.webhook.failed", {
80
- runId: failure.runId,
81
- attempts: failure.attempts,
82
- error: failure.error,
83
- });
84
- },
85
- },
86
- );
87
- // LAZY: async-notifier pulls in debounce + cron helpers — defer
88
- // until the first lifecycle install (deferred import within module).
89
- void import("../async-notifier.ts").then(({ startAsyncRunNotifier }) => {
90
- // LAZY: defer async-notifier until the lifecycle event fires
91
- if (deps.isCleanedUp()) return;
92
- startAsyncRunNotifier(ctx, deps.notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
93
- generation: deps.ownerGeneration,
94
- isCurrent: (generation) => generation === deps.ownerGeneration && deps.isContextCurrent(ctx, deps.ownerGeneration),
95
- webhookNotifier,
96
- });
97
- });
98
- return true;
99
- } catch (error) {
100
- state.notifierStarted = false;
101
- logInternalError("register.startLifecycleWatchers", error);
102
- return false;
103
- }
104
- }
105
-
106
- /**
107
- * Stop the async-run notifier. Safe to call when not started.
108
- */
109
- export function stopLifecycleWatchers(state: LifecycleState, deps: LifecycleDeps): void {
110
- if (!state.notifierStarted) return;
111
- state.notifierStarted = false;
112
- try {
113
- void import("../async-notifier.ts").then(({ stopAsyncRunNotifier }) => {
114
- // LAZY: defer async-notifier until the lifecycle event fires
115
- stopAsyncRunNotifier(deps.notifierState);
116
- });
117
- } catch (error) {
118
- logInternalError("register.stopLifecycleWatchers", error);
119
- }
120
- }
121
-
122
39
  /** Dependencies for configureNotifications (UI-facing delivery wiring). */
123
40
  export interface NotificationsDeps {
124
41
  pi: ExtensionAPI;