pi-crew 0.11.1 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -20,7 +20,12 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { loadConfig } from "../../config/config.ts";
21
21
  import { DEFAULT_UI } from "../../config/defaults.ts";
22
22
  import { getCrewEnv } from "../../config/env-vars.ts";
23
- import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
23
+ import {
24
+ pruneFinishedRuns,
25
+ pruneUserLevelRuns,
26
+ resolveAutoPruneAgeFloorMs,
27
+ resolveAutoPruneKeep,
28
+ } from "../../extension/run-maintenance.ts";
24
29
  import { type BrokerSpawnCredentials, setActiveBrokerIssuer, setActiveBrokerRevoker } from "../../runtime/broker/broker-issuer.ts";
25
30
  import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
26
31
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
@@ -37,9 +42,10 @@ import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/sched
37
42
  import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
38
43
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
39
44
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
45
+ import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
40
46
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
41
47
  import type { TeamRunManifest } from "../../state/types.ts";
42
- import { summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
48
+ import { overlayFreshTaskStatuses, summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
43
49
  import { installInlinePanel } from "../../ui/inline-panel/index.ts";
44
50
  import { clearSessionSwitchInFlight, markSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
45
51
  import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
@@ -64,8 +70,11 @@ import { notifyActiveRuns } from "../session-summary.ts";
64
70
  import { persistScheduledJobUpdate, registerCrewScheduler, stashScheduledJobsHiddenCount } from "../team-tool/handle-schedule.ts";
65
71
  import { handleTeamTool } from "../team-tool.ts";
66
72
  import { runArtifactCleanup } from "./artifact-cleanup.ts";
73
+ import { healthNotifyFingerprint, recordHealthNotifyDecision, resetHealthNotifyEntry } from "./health-notify-policy.ts";
67
74
  import type { RegistrationContext } from "./registration-types.ts";
68
75
  import { createScheduleEventNotifier } from "./schedule-toast-bridge.ts";
76
+ import { purgeQueuedAmbientNotifications } from "./subagent-helpers.ts";
77
+ import { refreshCrossExtensionWiringForSession } from "./wire-cross-extension.ts";
69
78
 
70
79
  /**
71
80
  * Register all session-lifecycle handlers on the ExtensionAPI. The caller
@@ -251,6 +260,10 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
251
260
  // it can issue tokens for child runs in this session. The controller
252
261
  // already gates by flag + root-session; this is a no-op when disabled.
253
262
  ctx.brokerController?.setSessionId(currentSessionId);
263
+ // F13 (RR-018): RPC wiring is extension-lifetime and survives session
264
+ // switches; rebind the crew global registry to THIS session's manifest
265
+ // cache (idempotent — see wire-cross-extension.ts).
266
+ refreshCrossExtensionWiringForSession(pi, ctx);
254
267
 
255
268
  // Defer ALL heavy cleanup to after the session_start handler returns.
256
269
  // These operations involve synchronous directory scanning (readdirSync, readFileSync)
@@ -443,24 +456,35 @@ async function runDeferredSessionCleanup(
443
456
 
444
457
  // Reconcile stale runs found on disk
445
458
  try {
446
- const staleResults =
447
- reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId) ?? [];
448
- if (staleResults.length > 0) {
449
- ctx.notifyOperator({
450
- id: "stale_reconcile",
451
- severity: "info",
452
- source: "crash-recovery",
453
- title: `Reconciled ${staleResults.length} stale run(s)`,
454
- body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
459
+ // RR-021 WI-1.5: reconcile is async (mapConcurrent bound 4) — keep this
460
+ // session_start callback synchronous; notify when the result lands.
461
+ void reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId)
462
+ .then((staleResults) => {
463
+ if ((staleResults ?? []).length > 0) {
464
+ ctx.notifyOperator({
465
+ id: "stale_reconcile",
466
+ severity: "info",
467
+ source: "crash-recovery",
468
+ title: `Reconciled ${staleResults.length} stale run(s)`,
469
+ body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
470
+ });
471
+ }
472
+ })
473
+ .catch((error) => {
474
+ logInternalError("register.sessionStart.reconcileStale", error);
455
475
  });
456
- }
457
476
  } catch (error) {
458
477
  logInternalError("register.sessionStart.reconcileStale", error);
459
478
  }
460
479
 
461
480
  // Auto-prune finished project-level run directories
462
481
  try {
463
- const { removed } = pruneFinishedRuns(extensionCtx.cwd, 10);
482
+ // DP-01: keep + age-floor are env-driven; the age floor means a run
483
+ // finished <24h ago survives a session restart even beyond top-keep.
484
+ const { removed } = pruneFinishedRuns(extensionCtx.cwd, resolveAutoPruneKeep(), {
485
+ ageFloorMs: resolveAutoPruneAgeFloorMs(),
486
+ intent: "session-start-auto",
487
+ });
464
488
  if (removed.length > 0) {
465
489
  ctx.notifyOperator({
466
490
  id: `auto_prune_project`,
@@ -476,7 +500,10 @@ async function runDeferredSessionCleanup(
476
500
 
477
501
  // Auto-prune finished user-level run directories
478
502
  try {
479
- const { removed } = pruneUserLevelRuns(10);
503
+ const { removed } = pruneUserLevelRuns(resolveAutoPruneKeep(), {
504
+ ageFloorMs: resolveAutoPruneAgeFloorMs(),
505
+ intent: "session-start-auto",
506
+ });
480
507
  if (removed.length > 0) {
481
508
  ctx.notifyOperator({
482
509
  id: `auto_prune_user`,
@@ -648,6 +675,18 @@ export function evictRunFromManifests(manifests: TeamRunManifest[], runId: strin
648
675
  return manifests.filter((m) => m.runId !== runId);
649
676
  }
650
677
 
678
+ /**
679
+ * F14 (RR-019): cheap signature of the preloaded-manifest frame. Only a
680
+ * CHANGED signature lets `backgroundPreload` count as render activity
681
+ * (`schedule()`); an identical signature means "nothing the widget would
682
+ * paint differently" and must NOT reset the render scheduler's idle
683
+ * counters (that defeated the R1 idle stop — see setupRenderLoop). Covers
684
+ * run identity, status transitions, and updatedAt bumps. Exported for tests.
685
+ */
686
+ export function manifestsFrameSignature(manifests: TeamRunManifest[]): string {
687
+ return manifests.map((m) => `${m.runId}:${m.status}:${m.updatedAt}`).join("|");
688
+ }
689
+
651
690
  /**
652
691
  * Apply a runEventBus payload to a preloaded-manifest frame: evict the run on
653
692
  * terminal events, pass through unchanged otherwise. This is the exact logic
@@ -677,16 +716,24 @@ function setupRenderLoop(
677
716
  let lastPreloadedManifests: TeamRunManifest[] = [];
678
717
  let lastFrameManifestCache: ReturnType<typeof createManifestCache> | undefined;
679
718
  let lastFrameSnapshotCache: ReturnType<typeof createRunSnapshotCache> | undefined;
719
+ // F14: signature of the last frame backgroundPreload saw — only a change
720
+ // here counts as render activity.
721
+ let lastFrameSignature: string | undefined;
680
722
 
681
723
  const ownerGeneration = ctx.sessionGeneration;
682
724
 
683
- const buildFrame = async (): Promise<boolean> => {
684
- if (!ctx.currentCtx) return false;
725
+ const buildFrame = async (): Promise<{ ok: boolean; changed: boolean }> => {
726
+ if (!ctx.currentCtx) return { ok: false, changed: false };
685
727
  lastPreloadedConfig = loadConfig(ctx.currentCtx.cwd);
686
728
  lastFrameManifestCache = ctx.getManifestCache(ctx.currentCtx.cwd);
687
729
  lastFrameSnapshotCache = ctx.getRunSnapshotCache(ctx.currentCtx.cwd);
688
730
  const manifests = lastFrameManifestCache.list(20);
689
731
  lastPreloadedManifests = manifests;
732
+ // F14 (RR-019): compute the frame signature BEFORE the preload await so
733
+ // `changed` reflects what this tick saw, not a mid-preload mutation.
734
+ const frameSignature = manifestsFrameSignature(manifests);
735
+ const frameChanged = frameSignature !== lastFrameSignature;
736
+ lastFrameSignature = frameSignature;
690
737
  // pts/2 hang fix: reconcile per-run watchers against the ACTIVE set only.
691
738
  {
692
739
  const onRunChange = (runId: string): void => {
@@ -718,16 +765,23 @@ function setupRenderLoop(
718
765
  }
719
766
  const runIds = manifests.map((r) => r.runId);
720
767
  await lastFrameSnapshotCache.preloadAllStale(runIds);
721
- return true;
768
+ return { ok: true, changed: frameChanged };
722
769
  };
723
770
 
724
771
  const backgroundPreload = (): void => {
725
772
  if (!ctx.currentCtx || preloading) return;
726
773
  preloading = true;
727
774
  buildFrame()
728
- .then((ok) => {
775
+ .then(({ ok, changed }) => {
729
776
  preloading = false;
730
- if (ok) ctx.renderScheduler?.schedule();
777
+ // F14 (RR-019): preload completion is maintenance, NOT user activity.
778
+ // Only a genuinely CHANGED frame may call schedule() — an unchanged
779
+ // frame calling schedule() reset `lastEventAt`/`idleFallbackRenders`
780
+ // and re-armed the fallback loop that fallbackLoop() had just
781
+ // deliberately stopped (R1), rendering forever while idle. Real
782
+ // work still animates: runEventBus + fs.watch subscriptions call
783
+ // schedule() directly on genuine events.
784
+ if (ok && changed) ctx.renderScheduler?.schedule();
731
785
  })
732
786
  .catch((error: unknown) => {
733
787
  preloading = false;
@@ -796,7 +850,16 @@ function setupRenderLoop(
796
850
  const clearHealthNotifications = (runId: string): void => {
797
851
  for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
798
852
  const key = `${kind}_${runId}`;
799
- ctx.autoRecoveryLast.delete(key);
853
+ // FINDING 6: reset the fire budget (a genuine recurrence after
854
+ // this clear re-notifies) AND opportunistically purge
855
+ // still-QUEUED host follow-up copies of the original warning —
856
+ // the host drains the queue one message per turn boundary, so
857
+ // stale copies otherwise drip in for hours after the clear
858
+ // (live: 5h of "missing heartbeat" replays after the 2026-09-23
859
+ // zombie cleanup). The purge is feature-detected: hosts without
860
+ // clearQueuedUserMessagesMatching (e.g. 0.87.0) no-op here; the
861
+ // fire-cap policy bounds the backlog on every host.
862
+ resetHealthNotifyEntry({ entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES }, key);
800
863
  ctx.notifyOperator({
801
864
  id: key,
802
865
  clear: true,
@@ -805,6 +868,10 @@ function setupRenderLoop(
805
868
  runId,
806
869
  title: `Cleared ${kind} for ${runId}`,
807
870
  });
871
+ purgeQueuedAmbientNotifications(
872
+ pi,
873
+ (text) => text.includes(runId) && (text.includes("dead worker") || text.includes("missing heartbeat")),
874
+ );
808
875
  }
809
876
  };
810
877
  for (const run of sessionManifests) {
@@ -838,29 +905,29 @@ function setupRenderLoop(
838
905
  clearHealthNotifications(run.runId);
839
906
  continue;
840
907
  }
841
- const summary = summarizeHeartbeats(snapshot, { now });
908
+ // GATE 3 (FINDING 5) — task-status truth lives on DISK, not in the
909
+ // snapshot cache. A worker parked on `ask` writes running → waiting
910
+ // to tasks.json immediately, but the cached snapshot can lag that
911
+ // transition; in the window a parked (alive, silent-by-design)
912
+ // worker counted as active-without-heartbeat and fired a false
913
+ // "dead worker" (live: team_20260923100114, 01_explore parked on
914
+ // ask). Overlay FRESH statuses before summarizing; divergence also
915
+ // invalidates the stale cache entry so the next tick rebuilds it.
916
+ const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
917
+ const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
918
+ if (overlaid !== snapshot) snapshotCache.invalidate(run.runId);
919
+ const summary = summarizeHeartbeats(overlaid, { now });
920
+ const fingerprint = healthNotifyFingerprint(summary, overlaid.tasks.length);
842
921
  const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
843
922
  if (count <= 0) return;
844
923
  const key = `${kind}_${run.runId}`;
845
- const previous = ctx.autoRecoveryLast.get(key);
846
- if (previous !== undefined && now - previous.lastAccessAt < 5 * 60_000) return;
847
- // Defensive cap: evict entry with oldest lastAccessAt before inserting/updating.
848
- while (ctx.autoRecoveryLast.size >= ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES) {
849
- let oldestKey: string | undefined;
850
- let oldestAccess = Infinity;
851
- for (const [k, v] of ctx.autoRecoveryLast) {
852
- if (v.lastAccessAt < oldestAccess) {
853
- oldestAccess = v.lastAccessAt;
854
- oldestKey = k;
855
- }
856
- }
857
- if (oldestKey === undefined) break;
858
- ctx.autoRecoveryLast.delete(oldestKey);
859
- }
860
- ctx.autoRecoveryLast.set(key, {
861
- insertedAt: now,
862
- lastAccessAt: now,
863
- });
924
+ // FINDING 6: bounded re-fire — ≤ MAX_HEALTH_NOTIFY_FIRES per
925
+ // UNCHANGED fingerprint (5-min cooldown between fires). The
926
+ // old forever-re-arming cooldown fed the host follow-up queue
927
+ // duplicates that drained one-per-turn-boundary for hours.
928
+ // Policy detail + LRU eviction: health-notify-policy.ts.
929
+ const state = { entries: ctx.autoRecoveryLast, maxEntries: ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES };
930
+ if (!recordHealthNotifyDecision(state, key, fingerprint, now)) return;
864
931
  ctx.notifyOperator({
865
932
  id: key,
866
933
  severity: "warning",
@@ -22,6 +22,7 @@ import { projectCrewRoot } from "../../utils/paths.ts";
22
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";
25
26
 
26
27
  /**
27
28
  * Mutable state owned by register.ts and read/written by this module.
@@ -59,13 +60,39 @@ export function startLifecycleWatchers(ctx: ExtensionContext, state: LifecycleSt
59
60
  const loadedConfig = loadConfig(ctx.cwd);
60
61
  state.notifierStarted = true;
61
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
+ );
62
87
  // LAZY: async-notifier pulls in debounce + cron helpers — defer
63
88
  // until the first lifecycle install (deferred import within module).
64
89
  void import("../async-notifier.ts").then(({ startAsyncRunNotifier }) => {
90
+ // LAZY: defer async-notifier until the lifecycle event fires
65
91
  if (deps.isCleanedUp()) return;
66
92
  startAsyncRunNotifier(ctx, deps.notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
67
93
  generation: deps.ownerGeneration,
68
94
  isCurrent: (generation) => generation === deps.ownerGeneration && deps.isContextCurrent(ctx, deps.ownerGeneration),
95
+ webhookNotifier,
69
96
  });
70
97
  });
71
98
  return true;
@@ -84,6 +111,7 @@ export function stopLifecycleWatchers(state: LifecycleState, deps: LifecycleDeps
84
111
  state.notifierStarted = false;
85
112
  try {
86
113
  void import("../async-notifier.ts").then(({ stopAsyncRunNotifier }) => {
114
+ // LAZY: defer async-notifier until the lifecycle event fires
87
115
  stopAsyncRunNotifier(deps.notifierState);
88
116
  });
89
117
  } catch (error) {
@@ -107,6 +135,13 @@ export interface NotificationsDeps {
107
135
  ctx: ExtensionContext,
108
136
  notificationCount: number,
109
137
  ) => void;
138
+ /**
139
+ * F11 (RR-018): ownership predicate — true only while the session that
140
+ * started this configure is still current. Checked after every lazy-import
141
+ * await boundary before publishing into shared state. Optional for older
142
+ * hand-built deps; defaults to "still owner".
143
+ */
144
+ isOwnerSession?: () => boolean;
110
145
  }
111
146
 
112
147
  /**
@@ -117,6 +152,11 @@ export interface NotificationsDeps {
117
152
  * - `config.notifications?.enabled === false` → no-op.
118
153
  */
119
154
  export async function configureNotifications(ctx: ExtensionContext, state: LifecycleState, deps: NotificationsDeps): Promise<void> {
155
+ // F11 (RR-018): ownership predicate — re-verified after every await below.
156
+ // A continuation whose session was cleaned up or superseded must not
157
+ // publish resources into shared state. Optional so hand-built deps from
158
+ // older call sites keep working (defaults to "still owner").
159
+ const stillOwns = deps.isOwnerSession ?? (() => true);
120
160
  state.notificationRouter?.dispose();
121
161
  state.notificationSink?.dispose();
122
162
  state.notificationRouter = undefined;
@@ -126,26 +166,29 @@ export async function configureNotifications(ctx: ExtensionContext, state: Lifec
126
166
 
127
167
  // LAZY: notification router — wires events to sinks
128
168
  const { NotificationRouter } = await import("../notification-router.ts");
169
+ if (!stillOwns()) return;
129
170
  // LAZY: JSONL sink — file-backed notification storage
130
171
  const { createJsonlSink } = await import("../notification-sink.ts");
172
+ if (!stillOwns()) return;
131
173
  // LAZY: follow-up helpers — shared with subagent handoff
132
174
  const { sendFollowUp } = await import("./subagent-helpers.ts");
175
+ if (!stillOwns()) return;
133
176
  // LAZY: widget updater — refreshes the TUI status widget
134
177
  const { updateCrewWidget } = await import("../../ui/widget/index.ts");
178
+ if (!stillOwns()) return;
135
179
 
136
- if (config.telemetry?.enabled !== false) {
137
- state.notificationSink = createJsonlSink(
138
- projectCrewRoot(ctx.cwd),
139
- config.notifications?.sinkRetentionDays ?? DEFAULT_NOTIFICATIONS.sinkRetentionDays,
140
- );
141
- }
142
- state.notificationRouter = new NotificationRouter(
180
+ // F11: create into locals — publish only while ownership still holds.
181
+ const notificationSink =
182
+ config.telemetry?.enabled !== false
183
+ ? createJsonlSink(projectCrewRoot(ctx.cwd), config.notifications?.sinkRetentionDays ?? DEFAULT_NOTIFICATIONS.sinkRetentionDays)
184
+ : undefined;
185
+ const notificationRouter = new NotificationRouter(
143
186
  {
144
187
  dedupWindowMs: config.notifications?.dedupWindowMs ?? DEFAULT_NOTIFICATIONS.dedupWindowMs,
145
188
  batchWindowMs: config.notifications?.batchWindowMs ?? DEFAULT_NOTIFICATIONS.batchWindowMs,
146
189
  quietHours: config.notifications?.quietHours,
147
190
  severityFilter: config.notifications?.severityFilter ?? [...DEFAULT_NOTIFICATIONS.severityFilter],
148
- sink: (notification) => state.notificationSink?.write(notification),
191
+ sink: (notification) => notificationSink?.write(notification),
149
192
  },
150
193
  (notification: NotificationDescriptor) => {
151
194
  if (notification.clear) {
@@ -183,6 +226,15 @@ export async function configureNotifications(ctx: ExtensionContext, state: Lifec
183
226
  }
184
227
  },
185
228
  );
229
+ if (!stillOwns()) {
230
+ // F11: ownership lost while suspended — dispose what was just created
231
+ // instead of publishing (no orphans).
232
+ notificationRouter.dispose();
233
+ notificationSink?.dispose();
234
+ return;
235
+ }
236
+ state.notificationSink = notificationSink;
237
+ state.notificationRouter = notificationRouter;
186
238
  }
187
239
 
188
240
  /** Dispose notification router + sink. */
@@ -200,6 +252,8 @@ export interface DeliveryDeps {
200
252
  notifyOperator: (notification: NotificationDescriptor) => void;
201
253
  sendFollowUp: (pi: ExtensionAPI, message: string) => void;
202
254
  sendAgentWakeUp: (pi: ExtensionAPI, message: string) => void;
255
+ /** F11 (RR-018): same ownership predicate as NotificationsDeps.isOwnerSession. */
256
+ isOwnerSession?: () => boolean;
203
257
  }
204
258
 
205
259
  /**
@@ -207,15 +261,19 @@ export interface DeliveryDeps {
207
261
  * register.ts as part of H3-L2 (delivery lifecycle service).
208
262
  */
209
263
  export async function configureDeliveryCoordinator(state: LifecycleState, deps: DeliveryDeps): Promise<void> {
264
+ // F11: ownership predicate — re-verified after the lazy imports below.
265
+ const stillOwns = deps.isOwnerSession ?? (() => true);
210
266
  state.deliveryCoordinator?.dispose();
211
267
  state.deliveryCoordinator = undefined;
212
268
  state.overflowTracker?.dispose();
213
269
  state.overflowTracker = undefined;
214
270
  // LAZY: delivery coordinator — batches notification fan-out
215
271
  const { DeliveryCoordinator } = await import("../../runtime/delivery-coordinator.ts");
272
+ if (!stillOwns()) return;
216
273
  // LAZY: overflow tracker — recovers from backlog overflow
217
274
  const { OverflowRecoveryTracker } = await import("../../runtime/recovery/overflow-recovery.ts");
218
- state.deliveryCoordinator = new DeliveryCoordinator({
275
+ if (!stillOwns()) return;
276
+ const deliveryCoordinator = new DeliveryCoordinator({
219
277
  emit: (event, data) => {
220
278
  deps.pi.events?.emit?.(event, data);
221
279
  },
@@ -226,7 +284,7 @@ export async function configureDeliveryCoordinator(state: LifecycleState, deps:
226
284
  deps.sendAgentWakeUp(deps.pi, message);
227
285
  },
228
286
  });
229
- state.overflowTracker = new OverflowRecoveryTracker({
287
+ const overflowTracker = new OverflowRecoveryTracker({
230
288
  onPhaseChange: (phaseState, previousPhase) => {
231
289
  if (deps.observabilityState.metricRegistry) {
232
290
  deps.observabilityState.metricRegistry
@@ -254,6 +312,15 @@ export async function configureDeliveryCoordinator(state: LifecycleState, deps:
254
312
  });
255
313
  },
256
314
  });
315
+ if (!stillOwns()) {
316
+ // F11: ownership lost while suspended — dispose what was just created
317
+ // instead of publishing (no orphans).
318
+ deliveryCoordinator.dispose();
319
+ overflowTracker.dispose();
320
+ return;
321
+ }
322
+ state.deliveryCoordinator = deliveryCoordinator;
323
+ state.overflowTracker = overflowTracker;
257
324
  }
258
325
 
259
326
  /** Dispose delivery coordinator + overflow tracker. */