pi-crew 0.11.0 → 0.11.2
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 +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- 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 +1 -3
- package/schema.json +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- 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/register.ts +7 -2
- 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 +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- 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/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -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-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +33 -4
- 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/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- 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 +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -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 +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- 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/task-display.ts +1 -1
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +18 -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 +140 -15
- 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 +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- 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/utils/visual.ts +0 -4
- package/src/worktree/worktree-manager.ts +206 -26
- 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/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DP-04 (2026-09-22): small-goal routing hint.
|
|
3
|
+
*
|
|
4
|
+
* Measured: a trivial goal through the default team burns 7.6–9k tokens just on
|
|
5
|
+
* the 3 context-setup steps (assess → execute → verify). The engine ALREADY
|
|
6
|
+
* supports `singleAgent: true` (handlePlan → composeSingleAgentPrompt); nothing
|
|
7
|
+
* suggested it for small goals.
|
|
8
|
+
*
|
|
9
|
+
* This module decides the hint. It is deliberately PURE and explainable — no
|
|
10
|
+
* ML, no hidden state — so the routing decision can be asserted in tests and
|
|
11
|
+
* audited from the plan output. Default mode is "suggest" (visible nudge, the
|
|
12
|
+
* caller/agent still decides), NOT a silent switch: this project has been
|
|
13
|
+
* burned by silent routing changes before (the auto-boomerang aliasing trap),
|
|
14
|
+
* so the decision must be visible.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type SmallGoalRoutingMode = "off" | "suggest" | "auto";
|
|
18
|
+
|
|
19
|
+
/** Goals at or below this many whitespace tokens are considered "small". */
|
|
20
|
+
export const SMALL_GOAL_TOKEN_THRESHOLD = 40;
|
|
21
|
+
|
|
22
|
+
export interface RoutingHintInput {
|
|
23
|
+
goal: string;
|
|
24
|
+
/** True when the caller supplied an explicit team and/or workflow override. */
|
|
25
|
+
explicitOverride: boolean;
|
|
26
|
+
/** Configured mode; defaults to "suggest". */
|
|
27
|
+
mode?: SmallGoalRoutingMode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RoutingHint {
|
|
31
|
+
/** Whether a cheaper single-agent composition is advised. */
|
|
32
|
+
suggested: boolean;
|
|
33
|
+
/** Machine-readable reason (empty when not suggested). */
|
|
34
|
+
reason?: string;
|
|
35
|
+
/** Human-readable, shown in the plan output. */
|
|
36
|
+
message?: string;
|
|
37
|
+
/** Rough token saving estimate for the plan text (informational). */
|
|
38
|
+
estimatedSavings?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Cheap, deterministic token count (whitespace split) — no tokenizer dep. */
|
|
42
|
+
export function goalTokenCount(goal: string): number {
|
|
43
|
+
const trimmed = goal.trim();
|
|
44
|
+
if (trimmed.length === 0) return 0;
|
|
45
|
+
return trimmed.split(/\s+/).length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveRoutingHint(input: RoutingHintInput): RoutingHint {
|
|
49
|
+
const mode = input.mode ?? "suggest";
|
|
50
|
+
if (mode === "off") return { suggested: false };
|
|
51
|
+
// Never second-guess an explicit team/workflow choice.
|
|
52
|
+
if (input.explicitOverride) return { suggested: false };
|
|
53
|
+
const tokens = goalTokenCount(input.goal);
|
|
54
|
+
if (tokens === 0 || tokens > SMALL_GOAL_TOKEN_THRESHOLD) return { suggested: false };
|
|
55
|
+
return {
|
|
56
|
+
suggested: true,
|
|
57
|
+
reason: `goal is small (${tokens} tokens ≤ ${SMALL_GOAL_TOKEN_THRESHOLD})`,
|
|
58
|
+
message:
|
|
59
|
+
`Hint: this goal is small (${tokens} tokens). The default team runs 3 context-setup phases ` +
|
|
60
|
+
`(~8k tokens overhead) before real work — consider \`singleAgent: true\` (or a 2-step chain) to skip them.`,
|
|
61
|
+
estimatedSavings: "~5-6k tokens",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.ts";
|
|
|
2
2
|
import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
|
|
3
3
|
import { extractCommandTrace } from "../../runtime/command-trace.ts";
|
|
4
4
|
import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
|
|
5
|
+
import { deadletterStatusLine } from "../../runtime/deadletter.ts";
|
|
5
6
|
import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
|
|
6
7
|
import { computePhaseProgress } from "../../runtime/phase-progress.ts";
|
|
7
8
|
import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
|
|
@@ -150,6 +151,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
150
151
|
}
|
|
151
152
|
const counts = new Map<string, number>();
|
|
152
153
|
for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
154
|
+
const deadletterLine = deadletterStatusLine(manifest);
|
|
153
155
|
const phaseProgress = computePhaseProgress(tasks);
|
|
154
156
|
// PERF (2026-08-24): intentionally NOT passing `limit` here — readEventsCursor's
|
|
155
157
|
// limit is a HEAD cap (oldest-first slice for streaming pagination), not a tail
|
|
@@ -267,6 +269,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
|
|
|
267
269
|
)
|
|
268
270
|
: ["- (none)"]),
|
|
269
271
|
`Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
|
|
272
|
+
// US-003 AC-3: surface exhausted-retry failures — only when entries exist.
|
|
273
|
+
...(deadletterLine ? [deadletterLine] : []),
|
|
270
274
|
"Effectiveness:",
|
|
271
275
|
`- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
|
|
272
276
|
`- workerExecution=${effectiveness.workerExecution} guard=${effectiveness.guardMode} severity=${effectiveness.severity}`,
|
|
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
4
4
|
import { allAgents, discoverAgents, listDynamicAgents, registerDynamicAgent, unregisterDynamicAgent } from "../agents/discover-agents.ts";
|
|
5
5
|
import { loadConfig } from "../config/config.ts";
|
|
6
|
+
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
6
7
|
import { getCrewEnv } from "../config/env-vars.ts";
|
|
7
8
|
// Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
|
|
8
9
|
// executeTeamRun is only called when a team run actually executes.
|
|
@@ -17,6 +18,7 @@ import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatu
|
|
|
17
18
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
18
19
|
import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
|
|
19
20
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
21
|
+
import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
20
22
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
21
23
|
import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
22
24
|
import { listRecentRuns } from "./run-index.ts";
|
|
@@ -191,9 +193,9 @@ function artifactKey(artifact: ArtifactDescriptor): string {
|
|
|
191
193
|
* passes) but handlers expect real numbers (TeamToolParamsValue types them as
|
|
192
194
|
* number). This converts the string form back to a number — and treats "" as
|
|
193
195
|
* unset (deleted) — before any domain router reads the param. */
|
|
194
|
-
const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline"] as const;
|
|
196
|
+
const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline", "budgetTotal"] as const;
|
|
195
197
|
|
|
196
|
-
function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
|
|
198
|
+
export function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
|
|
197
199
|
let mutated = false;
|
|
198
200
|
const out: Record<string, unknown> = { ...params };
|
|
199
201
|
for (const key of LOOSE_NUMERIC_PARAM_KEYS) {
|
|
@@ -400,7 +402,10 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
400
402
|
data: { runtimeResolution, action: "resume" },
|
|
401
403
|
});
|
|
402
404
|
if (runtime.safety === "blocked") {
|
|
403
|
-
const runningManifest = updateRunStatus(runtimeManifest, "running", "Checking worker runtime availability before resume."
|
|
405
|
+
const runningManifest = updateRunStatus(runtimeManifest, "running", "Checking worker runtime availability before resume.", {
|
|
406
|
+
// Resume is the ONE legitimate terminal-exit flow (finding 8 write guard).
|
|
407
|
+
allowTerminalExit: true,
|
|
408
|
+
});
|
|
404
409
|
const blocked = updateRunStatus(
|
|
405
410
|
runningManifest,
|
|
406
411
|
"blocked",
|
|
@@ -511,6 +516,8 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
|
|
|
511
516
|
reliability: decision.executedConfig.reliability,
|
|
512
517
|
metricRegistry: ctx.metricRegistry,
|
|
513
518
|
workspaceId: ctx.sessionId ?? ctx.cwd,
|
|
519
|
+
// Finding 8: resume is the legitimate terminal-exit flow.
|
|
520
|
+
isResume: true,
|
|
514
521
|
});
|
|
515
522
|
return result(
|
|
516
523
|
[
|
|
@@ -652,9 +659,48 @@ export function locateRunCwd(runId: string, baseCwd: string): string | undefined
|
|
|
652
659
|
* - Skips hidden entries (starting with `.`) unless they look like run directories
|
|
653
660
|
* (e.g. .crew, .pi, .tmp-crew-runs)
|
|
654
661
|
*/
|
|
662
|
+
/**
|
|
663
|
+
* F-L1 companion (2026-09-23 correction): a hit counts when the resolved
|
|
664
|
+
* stateRoot sits under EITHER root that `list` unions (run-index
|
|
665
|
+
* `scopedRunRoots` = userCrewRoot + projectCrewRoot), because this function
|
|
666
|
+
* answers "which cwd can read this run's state" — not "was it created here".
|
|
667
|
+
*
|
|
668
|
+
* History: the first F-L1 fix (5d3a3d0f) restricted hits to the candidate
|
|
669
|
+
* cwd's PRIMARY root. That kept `locateRunCwd` from claiming a run created in
|
|
670
|
+
* a sibling, but it also made every by-ID handler (status/events/summary/
|
|
671
|
+
* artifacts/worktrees/api/plans/respond/cancel) reject runs that `list` shows
|
|
672
|
+
* from the user root: `list` unions both roots, `loadRunManifestById` resolves
|
|
673
|
+
* both roots, yet `locateRunCwd` returned undefined → "Run not found" for
|
|
674
|
+
* runs the dashboard had just listed. Measured live: 10/10 user-root runs
|
|
675
|
+
* listed by `action='list'` failed `action='status'`.
|
|
676
|
+
*
|
|
677
|
+
* Sibling isolation is preserved WITHOUT the primary-only restriction: a run
|
|
678
|
+
* living under a sibling's PROJECT root (…/sibling/.crew/state/runs/<id>) is
|
|
679
|
+
* not under the candidate cwd's project root nor the user root, so it still
|
|
680
|
+
* does not match (pinned by locate-run-cwd.test.ts "sibling directory").
|
|
681
|
+
*/
|
|
682
|
+
function runUnderListedRoot(cwd: string, runId: string): boolean {
|
|
683
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
684
|
+
if (!loaded) return false;
|
|
685
|
+
const roots = findRepoRoot(cwd) ? [projectCrewRoot(cwd), userCrewRoot()] : [userCrewRoot()];
|
|
686
|
+
return roots.some((root) => loaded.manifest.stateRoot === path.join(root, DEFAULT_PATHS.state.runsSubdir, runId));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Scan-path variant: a CHILD directory may claim the run only when the run's
|
|
691
|
+
* state actually lives under that child's OWN crew root (the nested-child
|
|
692
|
+
* case this scan exists for). Without this, a shared user-root run would be
|
|
693
|
+
* "claimed" by whichever child happened to be scanned first.
|
|
694
|
+
*/
|
|
695
|
+
function runUnderOwnRoot(cwd: string, runId: string): boolean {
|
|
696
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
697
|
+
if (!loaded) return false;
|
|
698
|
+
return loaded.manifest.stateRoot === path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir, runId);
|
|
699
|
+
}
|
|
700
|
+
|
|
655
701
|
export function locateRunCwdUncached(runId: string, baseCwd: string): string | undefined {
|
|
656
|
-
// Fast path: run
|
|
657
|
-
if (
|
|
702
|
+
// Fast path: run resolves under one of the roots `list` shows (see helper note).
|
|
703
|
+
if (runUnderListedRoot(baseCwd, runId)) {
|
|
658
704
|
return baseCwd;
|
|
659
705
|
}
|
|
660
706
|
|
|
@@ -670,7 +716,7 @@ export function locateRunCwdUncached(runId: string, baseCwd: string): string | u
|
|
|
670
716
|
if (!entry.name.startsWith(".crew") && !entry.name.startsWith(".pi") && !entry.name.startsWith(".tmp-crew")) continue;
|
|
671
717
|
}
|
|
672
718
|
const candidate = path.join(baseCwd, entry.name);
|
|
673
|
-
if (
|
|
719
|
+
if (runUnderOwnRoot(candidate, runId)) {
|
|
674
720
|
return candidate;
|
|
675
721
|
}
|
|
676
722
|
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-030 (docs/specs/US-030.md) — outbound webhook notifications on run
|
|
3
|
+
* terminal transitions (completed/failed/cancelled).
|
|
4
|
+
*
|
|
5
|
+
* Pattern: bounded sibling of notification-router.ts / schedule-toast-bridge.ts
|
|
6
|
+
* — an opt-in, fire-and-forget sink. The terminal TRANSITION itself is
|
|
7
|
+
* observed by the async-run notifier (src/extension/async-notifier.ts, the
|
|
8
|
+
* existing poller that also toasts run completion for background runs);
|
|
9
|
+
* this module only formats + delivers. No new polling loop was introduced.
|
|
10
|
+
*
|
|
11
|
+
* Safety model (spec "SECURITY-SENSITIVE"):
|
|
12
|
+
* - Disabled by default: no `notifications.webhook.url` (or `enabled:false`)
|
|
13
|
+
* → the factory returns a shared no-op singleton — ZERO network calls and
|
|
14
|
+
* zero allocation on the hot path (notifyTerminalRun is an empty fn).
|
|
15
|
+
* - SSRF guard: URL must be http(s) and must not target localhost /
|
|
16
|
+
* 127.0.0.0/8 / 0.0.0.0 / [::1] / :: / fe80::/10 / 169.254.0.0/16 unless
|
|
17
|
+
* `allowLocalhost: true` is set explicitly (checked once at creation —
|
|
18
|
+
* fail closed). Hostname-LITERAL checks only (no DNS resolution): a DNS
|
|
19
|
+
* name that resolves to loopback is not detected — documented limitation.
|
|
20
|
+
* - PII-safe payload: event/runId/status/team/goal FIRST LINE/durationMs/
|
|
21
|
+
* cost/tokens/at only. NO transcripts, events, task bodies, or summaries.
|
|
22
|
+
* - The config block is `sensitive: true` in the schema, so project-level
|
|
23
|
+
* config drops it (user config only) — an untrusted repo cannot point
|
|
24
|
+
* pi-crew at an attacker URL.
|
|
25
|
+
* - Delivery never throws into the caller (async-notifier poller / run
|
|
26
|
+
* lifecycle path): every entry point is wrapped; failures land in
|
|
27
|
+
* logInternalError (always-on "warn") + a `crew.webhook.failed` pi event
|
|
28
|
+
* surfaced via deps.onFailure (wired in registration/lifecycle.ts).
|
|
29
|
+
*
|
|
30
|
+
* Delivery: ONE POST (content-type application/json) with a 5 s per-attempt
|
|
31
|
+
* timeout and EXACTLY ONE retry on 5xx/network error, then give up. Uses the
|
|
32
|
+
* Node 22 global fetch — no new dependency.
|
|
33
|
+
*/
|
|
34
|
+
import { createHmac } from "node:crypto";
|
|
35
|
+
import type { CrewWebhookConfig } from "../config/types.ts";
|
|
36
|
+
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
37
|
+
import type { TeamTaskState } from "../state/types.ts";
|
|
38
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
39
|
+
import { isInQuietHours } from "./notification-router.ts";
|
|
40
|
+
|
|
41
|
+
/** Documented payload (spec §Design). Field order matches the spec example. */
|
|
42
|
+
export interface WebhookPayload {
|
|
43
|
+
event: "run.terminal";
|
|
44
|
+
runId: string;
|
|
45
|
+
status: string;
|
|
46
|
+
team: string;
|
|
47
|
+
goal: string;
|
|
48
|
+
durationMs: number;
|
|
49
|
+
cost: number;
|
|
50
|
+
tokens: number;
|
|
51
|
+
at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Manifest projection the notifier needs — TeamRunManifest satisfies this structurally. */
|
|
55
|
+
export interface WebhookTerminalRun {
|
|
56
|
+
runId: string;
|
|
57
|
+
status: string;
|
|
58
|
+
team: string;
|
|
59
|
+
goal?: string;
|
|
60
|
+
createdAt?: string;
|
|
61
|
+
/** Working dir of the project that owns the run (task loader input). */
|
|
62
|
+
cwd: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WebhookDeliveryFailure {
|
|
66
|
+
runId: string;
|
|
67
|
+
/** Target URL — carries no secret material (the secret only ever lives in the signature header). */
|
|
68
|
+
url: string;
|
|
69
|
+
attempts: number;
|
|
70
|
+
error: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WebhookNotifier {
|
|
74
|
+
/** Fire-and-forget terminal notification. NEVER throws. */
|
|
75
|
+
notifyTerminalRun(run: WebhookTerminalRun): void;
|
|
76
|
+
/** Determinism seam: resolves once no delivery is in flight. */
|
|
77
|
+
settled(): Promise<void>;
|
|
78
|
+
/** Stop accepting new deliveries (in-flight ones still settle). */
|
|
79
|
+
dispose(): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WebhookNotifierDeps {
|
|
83
|
+
/** Fetch implementation (injectable for tests); default = Node 22 global fetch. */
|
|
84
|
+
fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
85
|
+
/** Clock for the quiet-hours gate and the payload `at` field; default `() => new Date()`. */
|
|
86
|
+
now?: () => Date;
|
|
87
|
+
/** Task loader for cost/token sums; default reads the run's tasks from disk (best-effort). */
|
|
88
|
+
loadTasks?: (run: WebhookTerminalRun) => TeamTaskState[] | undefined;
|
|
89
|
+
/** Extra failure surface (the wiring emits `crew.webhook.failed` here). logInternalError always fires. */
|
|
90
|
+
onFailure?: (failure: WebhookDeliveryFailure) => void;
|
|
91
|
+
/** Per-attempt timeout. Default 5000 ms (spec "e.g. 5 s"). */
|
|
92
|
+
timeoutMs?: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface WebhookNotifyOptions {
|
|
96
|
+
/** `notifications.webhook` config block (url/enabled/secret/allowLocalhost). */
|
|
97
|
+
webhook?: CrewWebhookConfig;
|
|
98
|
+
/** `notifications.quietHours` — the SAME gate as the TUI router (reuses isInQuietHours — do not duplicate the logic). */
|
|
99
|
+
quietHours?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
103
|
+
/** One attempt + exactly one retry (spec: "ONE retry on 5xx/network error"). */
|
|
104
|
+
const MAX_ATTEMPTS = 2;
|
|
105
|
+
|
|
106
|
+
// ── SSRF guard ─────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function ipv4Octets(host: string): number[] | undefined {
|
|
109
|
+
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return undefined;
|
|
110
|
+
const parts = host.split(".").map(Number);
|
|
111
|
+
if (!parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) return undefined;
|
|
112
|
+
return parts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True for localhost NAMES and loopback/unspecified/link-local address
|
|
117
|
+
* literals. RFC1918 (10/8, 172.16/12, 192.168/16) is deliberately NOT
|
|
118
|
+
* blocked — internal webhooks are a primary use case (spec blocks only
|
|
119
|
+
* localhost / 127.0.0.1 / [::1] / 169.254.0.0/16).
|
|
120
|
+
*/
|
|
121
|
+
function isLocalAddress(hostname: string): boolean {
|
|
122
|
+
const host = hostname.toLowerCase();
|
|
123
|
+
// URL.hostname keeps brackets on IPv6 literals — strip for matching. A
|
|
124
|
+
// trailing dot ("localhost.", FQDN form) also resolves to loopback via
|
|
125
|
+
// getaddrinfo — strip it too (security review finding).
|
|
126
|
+
let bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
127
|
+
if (bare.endsWith(".")) bare = bare.slice(0, -1);
|
|
128
|
+
if (bare === "localhost" || bare.endsWith(".localhost")) return true;
|
|
129
|
+
// IPv4 literal, incl. IPv4-mapped IPv6 (::ffff:a.b.c.d).
|
|
130
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
|
|
131
|
+
const v4 = ipv4Octets(mapped ? mapped[1] : bare);
|
|
132
|
+
if (v4) {
|
|
133
|
+
const [a, b] = v4;
|
|
134
|
+
// 127/8 loopback, 0/8 unspecified, 169.254/16 link-local.
|
|
135
|
+
return a === 127 || a === 0 || (a === 169 && b === 254);
|
|
136
|
+
}
|
|
137
|
+
if (bare === "::1" || bare === "::") return true;
|
|
138
|
+
// IPv6 link-local fe80::/10: first non-empty hextet in 0xfe80..0xfebf.
|
|
139
|
+
const firstHextet = bare.split(":").find((segment) => segment.length > 0);
|
|
140
|
+
if (firstHextet && /^[0-9a-f]{1,4}$/.test(firstHextet)) {
|
|
141
|
+
const value = Number.parseInt(firstHextet, 16);
|
|
142
|
+
return value >= 0xfe80 && value <= 0xfebf;
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** SSRF guard (pure, exported for tests): http(s) only + local-address refusal unless opted in. */
|
|
148
|
+
export function isWebhookUrlAllowed(rawUrl: string, allowLocalhost = false): boolean {
|
|
149
|
+
let parsed: URL;
|
|
150
|
+
try {
|
|
151
|
+
parsed = new URL(rawUrl);
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
156
|
+
// Credentials embedded in the URL would leak into logs — refuse them
|
|
157
|
+
// (use the `secret` HMAC field, never userinfo).
|
|
158
|
+
if (parsed.username || parsed.password) return false;
|
|
159
|
+
if (allowLocalhost) return true;
|
|
160
|
+
return !isLocalAddress(parsed.hostname);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Log-safe URL form: origin only — never path, query, or userinfo. */
|
|
164
|
+
function safeUrlForLog(url: string): string {
|
|
165
|
+
try {
|
|
166
|
+
return new URL(url).origin;
|
|
167
|
+
} catch {
|
|
168
|
+
return "(unparseable-url)";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── payload ────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function sumUsage(tasks: TeamTaskState[] | undefined): { cost: number; tokens: number } {
|
|
175
|
+
let cost = 0;
|
|
176
|
+
let tokens = 0;
|
|
177
|
+
for (const task of tasks ?? []) {
|
|
178
|
+
const usage = task.usage;
|
|
179
|
+
if (!usage) continue;
|
|
180
|
+
cost += usage.cost ?? 0;
|
|
181
|
+
tokens += (usage.input ?? 0) + (usage.output ?? 0);
|
|
182
|
+
}
|
|
183
|
+
// Round the FP cost sum at micro-USD precision — keeps the JSON stable and test-friendly.
|
|
184
|
+
return { cost: Math.round(cost * 1e6) / 1e6, tokens };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Pure payload builder (exported for tests). PII-safe by construction: only
|
|
188
|
+
* the documented fields; goal is reduced to its FIRST line. */
|
|
189
|
+
export function buildWebhookPayload(run: WebhookTerminalRun, tasks: TeamTaskState[] | undefined, now: Date): WebhookPayload {
|
|
190
|
+
const createdAtMs = run.createdAt ? new Date(run.createdAt).getTime() : Number.NaN;
|
|
191
|
+
const { cost, tokens } = sumUsage(tasks);
|
|
192
|
+
return {
|
|
193
|
+
event: "run.terminal",
|
|
194
|
+
runId: run.runId,
|
|
195
|
+
status: run.status,
|
|
196
|
+
team: run.team,
|
|
197
|
+
// First line, capped at 256 chars: goals of nested runs are
|
|
198
|
+
// agent-composable — an uncapped line is a data-stuffing channel to
|
|
199
|
+
// the configured endpoint (security review finding).
|
|
200
|
+
goal: ((run.goal ?? "").split(/\r?\n/)[0] ?? "").slice(0, 256),
|
|
201
|
+
durationMs: Number.isFinite(createdAtMs) ? Math.max(0, now.getTime() - createdAtMs) : 0,
|
|
202
|
+
cost,
|
|
203
|
+
tokens,
|
|
204
|
+
at: now.toISOString(),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── notifier ───────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
const DISABLED_NOTIFIER: WebhookNotifier = Object.freeze({
|
|
211
|
+
notifyTerminalRun: () => undefined,
|
|
212
|
+
settled: () => Promise.resolve(),
|
|
213
|
+
dispose: () => undefined,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function defaultLoadTasks(run: WebhookTerminalRun): TeamTaskState[] | undefined {
|
|
217
|
+
try {
|
|
218
|
+
return loadRunManifestById(run.cwd, run.runId)?.tasks;
|
|
219
|
+
} catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Active-config check (pure, exported for tests): url present + not disabled. */
|
|
225
|
+
export function isWebhookActive(config: { webhook?: CrewWebhookConfig }): boolean {
|
|
226
|
+
return Boolean(config.webhook?.url) && config.webhook?.enabled !== false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function createWebhookNotifier(options: WebhookNotifyOptions, deps: WebhookNotifierDeps = {}): WebhookNotifier {
|
|
230
|
+
const webhook = options.webhook;
|
|
231
|
+
const url = webhook?.url ?? "";
|
|
232
|
+
if (!isWebhookActive(options)) return DISABLED_NOTIFIER;
|
|
233
|
+
if (!isWebhookUrlAllowed(url, webhook?.allowLocalhost === true)) {
|
|
234
|
+
// Fail closed at creation: the notifier stays a no-op — a disallowed
|
|
235
|
+
// target must never receive a request, not even once.
|
|
236
|
+
logInternalError(
|
|
237
|
+
"webhook-notify.config",
|
|
238
|
+
new Error(
|
|
239
|
+
`refusing webhook URL '${safeUrlForLog(url)}' (SSRF guard); set notifications.webhook.allowLocalhost=true to target loopback/link-local explicitly`,
|
|
240
|
+
),
|
|
241
|
+
undefined,
|
|
242
|
+
"warn",
|
|
243
|
+
);
|
|
244
|
+
return DISABLED_NOTIFIER;
|
|
245
|
+
}
|
|
246
|
+
const secret = webhook?.secret;
|
|
247
|
+
const quietHours = options.quietHours;
|
|
248
|
+
const now = deps.now ?? (() => new Date());
|
|
249
|
+
const fetchImpl = deps.fetch ?? ((input: string, init: RequestInit) => fetch(input, init));
|
|
250
|
+
const loadTasks = deps.loadTasks ?? defaultLoadTasks;
|
|
251
|
+
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
252
|
+
const onFailure = deps.onFailure;
|
|
253
|
+
let disposed = false;
|
|
254
|
+
const inflight = new Set<Promise<void>>();
|
|
255
|
+
|
|
256
|
+
async function deliverOnce(body: string): Promise<Response> {
|
|
257
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
258
|
+
if (secret) {
|
|
259
|
+
// Signature over the EXACT serialized body the receiver sees, so the
|
|
260
|
+
// receiver can verify with hmac_sha256(secret, raw_body).
|
|
261
|
+
headers["x-pi-crew-signature"] = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
262
|
+
}
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
265
|
+
try {
|
|
266
|
+
// SSRF (security review HIGH): redirect:"follow" (the default) lets
|
|
267
|
+
// undici chase up to 20 hops WITHOUT re-running the guard — a
|
|
268
|
+
// 302 at the allowed host reaches 169.254.169.254 / loopback /
|
|
269
|
+
// internal RFC1918, and a 307 REPLAYS the signed body there. Manual
|
|
270
|
+
// mode surfaces any 3xx as a response we treat as failure below.
|
|
271
|
+
return await fetchImpl(url, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers,
|
|
274
|
+
body,
|
|
275
|
+
signal: controller.signal,
|
|
276
|
+
redirect: "manual",
|
|
277
|
+
});
|
|
278
|
+
} finally {
|
|
279
|
+
clearTimeout(timer);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function deliver(body: string, runId: string): Promise<void> {
|
|
284
|
+
let lastError: unknown = new Error("webhook delivery failed");
|
|
285
|
+
let attempts = 0;
|
|
286
|
+
while (attempts < MAX_ATTEMPTS) {
|
|
287
|
+
attempts++;
|
|
288
|
+
try {
|
|
289
|
+
const response = await deliverOnce(body);
|
|
290
|
+
// Always release the body — undici pins the socket until the body
|
|
291
|
+
// is drained/cancelled (default bodyTimeout ~300s), which a
|
|
292
|
+
// slow-trickle endpoint would exploit across fire-and-forget calls.
|
|
293
|
+
// biome-ignore lint/suspicious/noEmptyBlockStatements: intentional fire-and-forget — the cancel() rejection has nothing left to act on.
|
|
294
|
+
void response.body?.cancel?.().catch(() => {});
|
|
295
|
+
if (response.ok) return;
|
|
296
|
+
// 3xx under redirect:"manual" = a redirect we REFUSE to follow
|
|
297
|
+
// (the guard validated the configured URL, not the hop target).
|
|
298
|
+
// Permanent failure — no retry.
|
|
299
|
+
if (response.status >= 300 && response.status < 400) {
|
|
300
|
+
lastError = new Error(`refused redirect HTTP ${response.status} (SSRF guard — redirects are not followed)`);
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
304
|
+
// Retry ONLY on 5xx (and only once). 4xx is permanent — fail fast.
|
|
305
|
+
if (response.status >= 500 && attempts < MAX_ATTEMPTS) continue;
|
|
306
|
+
break;
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Network error / abort-timeout → exactly one retry.
|
|
309
|
+
lastError = error;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const failure: WebhookDeliveryFailure = {
|
|
313
|
+
runId,
|
|
314
|
+
// Origin only: the raw URL (path/query/userinfo) must never reach
|
|
315
|
+
// logs or the pi event stream (security review finding).
|
|
316
|
+
url: safeUrlForLog(url),
|
|
317
|
+
attempts,
|
|
318
|
+
error: lastError instanceof Error ? lastError.message : String(lastError),
|
|
319
|
+
};
|
|
320
|
+
logInternalError(
|
|
321
|
+
"webhook-notify.delivery",
|
|
322
|
+
lastError instanceof Error ? lastError : new Error(String(lastError)),
|
|
323
|
+
`runId=${runId} url=${failure.url} attempts=${failure.attempts}`,
|
|
324
|
+
"warn",
|
|
325
|
+
);
|
|
326
|
+
try {
|
|
327
|
+
onFailure?.(failure);
|
|
328
|
+
} catch (callbackError) {
|
|
329
|
+
logInternalError("webhook-notify.onFailure", callbackError, runId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
notifyTerminalRun(run) {
|
|
335
|
+
// NEVER throws into the caller (async-notifier poller → run lifecycle).
|
|
336
|
+
try {
|
|
337
|
+
if (disposed) return;
|
|
338
|
+
// Quiet-hours gate reuses the router's isInQuietHours — the SAME
|
|
339
|
+
// suppression window as the TUI notification path. A pattern-valid
|
|
340
|
+
// but clock-invalid value ("99:99-00:00" passes the schema regex)
|
|
341
|
+
// throws in parseHHMMRange; suppression is a nicety, not a gate —
|
|
342
|
+
// deliver anyway and log once (security review finding: the throw
|
|
343
|
+
// previously silenced every delivery with only a console line).
|
|
344
|
+
if (quietHours) {
|
|
345
|
+
try {
|
|
346
|
+
if (isInQuietHours(quietHours, now())) return;
|
|
347
|
+
} catch (quietError) {
|
|
348
|
+
logInternalError(
|
|
349
|
+
"webhook-notify.quiet-hours",
|
|
350
|
+
quietError instanceof Error ? quietError : new Error(String(quietError)),
|
|
351
|
+
`invalid quietHours='${quietHours}' — delivering anyway`,
|
|
352
|
+
"warn",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const tasks = loadTasks(run);
|
|
357
|
+
const payload = buildWebhookPayload(run, tasks, now());
|
|
358
|
+
const body = JSON.stringify(payload);
|
|
359
|
+
const tracked = deliver(body, run.runId).then(
|
|
360
|
+
() => undefined,
|
|
361
|
+
(error) => {
|
|
362
|
+
// deliver() is designed to never reject; belt-only.
|
|
363
|
+
logInternalError("webhook-notify.unexpected", error, run.runId, "warn");
|
|
364
|
+
},
|
|
365
|
+
);
|
|
366
|
+
inflight.add(tracked);
|
|
367
|
+
void tracked.then(() => {
|
|
368
|
+
inflight.delete(tracked);
|
|
369
|
+
});
|
|
370
|
+
} catch (error) {
|
|
371
|
+
logInternalError("webhook-notify.terminal", error, run.runId);
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
async settled() {
|
|
375
|
+
// Loop: new deliveries may start while awaiting (only relevant in tests).
|
|
376
|
+
while (inflight.size > 0) await Promise.all([...inflight]);
|
|
377
|
+
},
|
|
378
|
+
dispose() {
|
|
379
|
+
disposed = true;
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
@@ -56,13 +56,23 @@ export function createMetricFileSink(opts: MetricFileSinkOptions): MetricSink {
|
|
|
56
56
|
};
|
|
57
57
|
const writeSnapshot = (snapshots: MetricSnapshot[]): Promise<void> => {
|
|
58
58
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// RR-020 Fix 2 (cold-verify correction): gate on the crew root ALREADY
|
|
60
|
+
// EXISTING, mirroring notification-sink. The first version of this fix
|
|
61
|
+
// skipped only EMPTY snapshots — dead code in production, because
|
|
62
|
+
// wireEventToMetrics (observability.ts registration) registers ~15
|
|
63
|
+
// metrics at init, so registry.snapshot() is never []; the 60s interval
|
|
64
|
+
// therefore kept materialising <crewRoot>/state/metrics/ for projects
|
|
65
|
+
// that never ran a team. Existence gate ⇒ no-op until a real run
|
|
66
|
+
// creates the crew root; once it exists, writes (empty ticks included)
|
|
67
|
+
// are byte-identical to HEAD.
|
|
68
|
+
if (!fs.existsSync(opts.crewRoot)) return Promise.resolve();
|
|
61
69
|
const redacted = redactSecrets(snapshots);
|
|
62
70
|
if (!Array.isArray(redacted)) {
|
|
63
71
|
logInternalError("metric-sink.type", new Error("redactSecrets did not return an array"), `got=${typeof redacted}`);
|
|
64
72
|
return Promise.resolve();
|
|
65
73
|
}
|
|
74
|
+
const now = new Date();
|
|
75
|
+
const date = now.toISOString().slice(0, 10);
|
|
66
76
|
const target = ensureFd(date);
|
|
67
77
|
const line = `${JSON.stringify({ exportedAt: now.toISOString(), snapshots: redacted as MetricSnapshot[] })}\n`;
|
|
68
78
|
// Async write to avoid blocking the main thread on the 60s tick.
|