pi-crew 0.11.1 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +742 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +5 -4
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +206 -41
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +55 -8
- package/src/extension/crew-input-router.ts +9 -2
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/plan-orchestrate.ts +19 -3
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +34 -10
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/foreground-run-controller.ts +2 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +38 -0
- package/src/extension/registration/lifecycle-handlers.ts +107 -40
- package/src/extension/registration/lifecycle.ts +77 -10
- package/src/extension/registration/observability.ts +125 -53
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/team-tool.ts +9 -2
- package/src/extension/registration/wire-cross-extension.ts +30 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-manager-command.ts +3 -2
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal-wrap.ts +7 -0
- package/src/extension/team-tool/goal.ts +16 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/parallel-dispatch.ts +16 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/run-intent.ts +1 -1
- package/src/extension/team-tool/run.ts +1 -1
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +123 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +76 -71
- package/src/runtime/broker/delegate/delegate-event.ts +12 -8
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/protocol/connection-state.ts +10 -0
- package/src/runtime/broker/protocol/lock-busy.ts +76 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +21 -3
- package/src/runtime/deterministic-ast.ts +14 -2
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
- package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
- package/src/runtime/group-join.ts +11 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/live-session-runtime.ts +1 -1
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/model/runtime-warmup.ts +2 -0
- package/src/runtime/output/output-validator.ts +10 -5
- package/src/runtime/plan-approval.ts +14 -1
- package/src/runtime/recovery/crash-recovery.ts +96 -49
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/subagent-manager.ts +43 -13
- package/src/runtime/task-runner/child-executor.ts +17 -6
- package/src/runtime/task-runner/post-execution.ts +44 -14
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +150 -45
- package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
- package/src/runtime/task-runner.ts +28 -1
- package/src/runtime/team-runner.ts +42 -2
- package/src/runtime/verification/verification-worktree.ts +3 -0
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +147 -17
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +120 -23
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +22 -10
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/index.ts +1 -1
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/render-diff.ts +19 -6
- package/src/ui/run-dashboard.ts +109 -6
- package/src/ui/run-snapshot-cache.ts +27 -4
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/ui/widget/task-list.ts +8 -5
- package/src/ui/widget/widget-model.ts +3 -1
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/tsconfig.json +2 -0
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/ui/card-colors.ts +0 -168
- package/src/utils/completion-dedupe.ts +0 -63
- package/src/utils/resolve-shell.ts +0 -36
|
@@ -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 {
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|