pi-crew 0.8.5 → 0.8.7

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 CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.6] — General cold-start race fix (runtime module-graph warmup) (2026-06-17)
4
+
5
+ Fixes the `validateWorkflowForTeam` cold-start crash that v0.8.1 did NOT
6
+ actually fix (honest correction — v0.8.1's per-import latch covered only the
7
+ peer-dep namespace `existsSync` variant, not this pi-crew-internal variant).
8
+
9
+ ### Corrected root cause
10
+
11
+ Under the **tsx loader**, a named import `import { X } from "mod"` compiles to
12
+ `mod_1.X` — a namespace-property access at runtime. If `mod_1` is observed
13
+ mid-instantiation as `undefined` during concurrent cold-start, the access
14
+ throws `Cannot read properties of undefined (reading 'X')`. **ANY module in
15
+ the graph is vulnerable**, not just the peer dep. v0.8.1's per-import latch
16
+ can't scale to every named import in `src/`.
17
+
18
+ ### Fix — general, not per-import
19
+
20
+ 1. **Pre-warm at registration.** `startRuntimeWarmup()` fires eager `import()`
21
+ of the hot module graph ROOTS during single-threaded `registerPiTeams()` —
22
+ before any subagent can spawn. ESM transitively instantiates their entire
23
+ import graph, so one import per path warms the full subgraph.
24
+ 2. **Await at spawn boundaries.** `awaitRuntimeWarmup()` is awaited at the top
25
+ of `runLiveSessionTask` and `runTeamTask` — the two spawn entry points — so
26
+ the graph is guaranteed warm before any module is touched, regardless of
27
+ which binding would otherwise race.
28
+
29
+ The warmup runs in milliseconds (module loading only). The await is
30
+ belt-and-suspenders for the pathological case where a spawn races the warmup
31
+ promise. Errors are swallowed — a failed warmup (e.g. peer dep absent) never
32
+ blocks the extension; worst case the old race returns (no worse than before).
33
+
34
+ ### Files
35
+ - NEW `src/runtime/runtime-warmup.ts` — `startRuntimeWarmup()` (idempotent
36
+ fire-and-forget) + `awaitRuntimeWarmup()` (gate) + test seams.
37
+ - `src/extension/register.ts` — `startRuntimeWarmup()` early in
38
+ `registerPiTeams`.
39
+ - `src/runtime/live-session-runtime.ts` — `await awaitRuntimeWarmup()` at top
40
+ of `runLiveSessionTask`.
41
+ - `src/runtime/task-runner.ts` — `await awaitRuntimeWarmup()` at top of
42
+ `runTeamTask`.
43
+ - NEW `test/unit/runtime-warmup.test.ts` (6 tests): idempotency, no-hang,
44
+ back-compat (no-op when not started), hot-module specifiers resolve,
45
+ integration (graph actually warms).
46
+ - Updated `.github/issues/2026-06-16-validateworkflowf-team-cold-start-race.md`
47
+ — marked RESOLVED with the fix applied.
48
+
49
+ typecheck clean; full suite 0 real failures (2 timer flakes under local load
50
+ pass 3/3 in isolation — clean on CI).
51
+
3
52
  ## [0.8.5] — Per-write validator (T5) + validateWorkflowForTeam race note (2026-06-16)
4
53
 
5
54
  Third APPLIED technique from the pi-ecosystem distillation (pi-lens /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -83,6 +83,7 @@ import { RenderScheduler } from "../ui/render-scheduler.ts";
83
83
  import { runEventBus } from "../ui/run-event-bus.ts";
84
84
  import { createTerminalStatusController, type TerminalStatusController } from "../ui/terminal-status.ts";
85
85
  import { extractPathFromInput, validateWrittenFile, buildValidationBlocker } from "../runtime/per-write-validator.ts";
86
+ import { startRuntimeWarmup } from "../runtime/runtime-warmup.ts";
86
87
  import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
87
88
  import { closeWatcher } from "../utils/fs-watch.ts";
88
89
  import { RunWatcherRegistry } from "../utils/run-watcher-registry.ts";
@@ -198,6 +199,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
198
199
  const disposeI18n = initI18n(pi);
199
200
  resetTimings();
200
201
  time("register:start");
202
+ // Cold-start race fix (general): pre-warm the hot module graph NOW, during
203
+ // single-threaded registration, before any concurrent subagent can spawn.
204
+ // Under the tsx loader, concurrent first-imports race module-record
205
+ // instantiation → `Cannot read properties of undefined (reading '<X>')` for
206
+ // ANY named import (observed: existsSync, validateWorkflowForTeam).
207
+ // Warming the graph here + awaiting it at spawn boundaries eliminates the
208
+ // race window. See src/runtime/runtime-warmup.ts.
209
+ startRuntimeWarmup();
201
210
  // Deploy bundled themes (crew-dark, crew-dracula, etc.) to ~/.pi/agent/themes/
202
211
  // so Pi's theme loader discovers them. Best-effort, idempotent.
203
212
  deployBundledThemes();
@@ -9,6 +9,7 @@ import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
9
9
  import { DEFAULT_PATHS } from "../../config/defaults.ts";
10
10
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
11
11
  import { getPiSpawnCommand } from "../../runtime/pi-spawn.ts";
12
+ import { getRuntimeWarmupStatus } from "../../runtime/runtime-warmup.ts";
12
13
  import { validateResources } from "../validate-resources.ts";
13
14
  import { detectDrift, formatDriftReport, type DriftReport } from "../../config/drift-detector.ts";
14
15
  import { TeamToolParams } from "../../schema/team-tool-schema.ts";
@@ -188,6 +189,37 @@ export function buildTeamDoctorReport(input: TeamDoctorReportInput): TeamDoctorR
188
189
  { label: "leader repository", ok: true, detail: input.cwd },
189
190
  { label: "cleanup policy", ok: true, detail: "dirty worktrees preserved unless force is set" },
190
191
  ]),
192
+ section("Runtime warmup (cold-start fix v0.8.6)", () => {
193
+ // Surface whether the general cold-start-race fix is active + how long
194
+ // the graph warmup took, so a session can confirm the fix loaded
195
+ // (post-restart) and isn't pathologically slow. An UNWARMED graph is
196
+ // the documented cause of `Cannot read properties of undefined
197
+ // (reading '<binding>')` under concurrent subagent spawn.
198
+ //
199
+ // "Not started" is NOT a doctor error: it is the normal state in unit
200
+ // tests and in any caller that invokes buildTeamDoctorReport directly
201
+ // without going through registerPiTeams. Only a STARTED-but-FAILED
202
+ // warmup is an error (something genuinely went wrong during pre-warm).
203
+ const status = getRuntimeWarmupStatus();
204
+ const checks: DoctorCheck[] = [
205
+ {
206
+ label: "warmup started",
207
+ ok: true, // informational — "not started" is not a failure
208
+ detail: status.started ? "module graph pre-warmed at registration" : "not started in this process (normal for direct unit-test calls; in a live Pi session, started at extension load)",
209
+ },
210
+ ];
211
+ if (status.started) {
212
+ checks.push({
213
+ label: "warmup completed",
214
+ ok: status.completed,
215
+ detail: status.completed ? (status.durationMs !== undefined ? `graph warm in ${status.durationMs}ms` : "completed") : "in progress",
216
+ });
217
+ if (status.error) {
218
+ checks.push({ label: "warmup error", ok: false, detail: status.error });
219
+ }
220
+ }
221
+ return checks;
222
+ }),
191
223
  ];
192
224
  if (input.smokeChildPi) {
193
225
  sections.push([`Child check`, `- ${input.smokeChildPi.ok ? "OK" : "FAIL"} child Pi smoke: ${input.smokeChildPi.detail}`]);
@@ -18,6 +18,7 @@ import { buildConfiguredModelRouting } from "./model-fallback.ts";
18
18
  import { readEnabledModelsPatterns } from "./model-scope.ts";
19
19
  import { resolveToolPolicy } from "../agents/agent-config.ts";
20
20
  import { loadConfig } from "../config/config.ts";
21
+ import { awaitRuntimeWarmup } from "./runtime-warmup.ts";
21
22
  import { DEFAULT_LIVE_SESSION } from "../config/defaults.ts";
22
23
  import { buildYieldReminder, hasYieldInOutput, isYieldEvent, extractYieldResult, validateYieldData, DEFAULT_YIELD_CONFIG, type YieldResult } from "./yield-handler.ts";
23
24
  import { buildMcpProxyFromSession } from "./mcp-proxy.ts";
@@ -396,6 +397,10 @@ export async function probeLiveSessionRuntime(): Promise<LiveSessionUnavailableR
396
397
  }
397
398
 
398
399
  export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<LiveSessionRunResult> {
400
+ // Cold-start race fix: ensure the hot module graph is warm before touching
401
+ // any module. Under tsx, concurrent first-imports race module-record
402
+ // instantiation; awaiting the registration-time warmup eliminates the window.
403
+ await awaitRuntimeWarmup();
399
404
  const isCurrent = input.isCurrent ?? (() => true);
400
405
  let streamOut: StreamingOutputHandle | undefined;
401
406
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Runtime module-graph warmup — general fix for the cold-start race.
3
+ *
4
+ * Problem: when N in-process live-session subagents spawn CONCURRENTLY, the
5
+ * tsx loader can race module-record instantiation, yielding
6
+ * `Cannot read properties of undefined (reading '<binding>')` for ANY named
7
+ * import in the hot graph (observed: `existsSync`, `validateWorkflowForTeam`).
8
+ * v0.8.1's per-import latch covered only the peer-dep namespace; this is the
9
+ * GENERAL fix.
10
+ *
11
+ * Root cause: under tsx, a named import `import { X } from "mod"` compiles to
12
+ * `mod_1.X` — a namespace-property access at runtime. If `mod_1` (the module
13
+ * namespace object) is observed mid-instantiation as `undefined`, the access
14
+ * throws. ANY module in the graph is vulnerable, not just the peer dep. So
15
+ * per-import latching doesn't scale.
16
+ *
17
+ * Fix: pre-warm the hot module graph during SINGLE-THREADED extension
18
+ * registration (before any subagent can spawn), then `await` the warmup at
19
+ * every spawn entry point. By the time concurrent subagents touch the graph,
20
+ * every module record is fully instantiated → no race window.
21
+ *
22
+ * Why this is general (not per-import):
23
+ * - `startRuntimeWarmup()` fires `import()` for the ROOT modules of each
24
+ * hot execution path. ESM transitively instantiates their entire import
25
+ * graph, so one import per path warms the whole reachable subgraph.
26
+ * - `awaitRuntimeWarmup()` is the gate: spawn paths await it before
27
+ * touching any module, guaranteeing the graph is warm regardless of
28
+ * which specific binding races.
29
+ *
30
+ * The warmup runs during `registerPiTeams()` (single-threaded, before pi
31
+ * accepts user input). It resolves in milliseconds (module loading only, no
32
+ * I/O for local modules). The `await` at spawn boundaries is belt-and-
33
+ * suspenders for pathological cases where a spawn races the warmup promise.
34
+ *
35
+ * @module runtime-warmup
36
+ */
37
+
38
+ /**
39
+ * The modules whose transitive import graphs cover every hot execution path
40
+ * a concurrent subagent can reach. Importing the ROOT of each path warms the
41
+ * full subgraph (ESM instantiates imports eagerly during `import()`).
42
+ *
43
+ * - `./live-session-runtime.ts` — the in-process subagent spawn path
44
+ * (pulls in the peer dep + the entire runtime layer).
45
+ * - `./task-runner.ts` — the child-process task dispatch path.
46
+ * - `../extension/team-tool.ts` — the team tool (pulls in
47
+ * validate-resources → validate-workflow, the `validateWorkflowForTeam` site).
48
+ * - `../extension/validate-resources.ts` — direct warm of the aggregator.
49
+ * - `@earendil-works/pi-coding-agent` — the peer dep (the `existsSync` site;
50
+ * also latched in v0.8.1 — defense in depth).
51
+ */
52
+ const HOT_MODULE_SPECIFIERS = [
53
+ "./live-session-runtime.ts",
54
+ "./task-runner.ts",
55
+ "../extension/team-tool.ts",
56
+ "../extension/validate-resources.ts",
57
+ ] as const;
58
+
59
+ /** Additional bare-specifier peer deps to warm. */
60
+ const HOT_PEER_DEPS = ["@earendil-works/pi-coding-agent"] as const;
61
+
62
+ let warmupPromise: Promise<void> | undefined;
63
+ let warmupStarted = false;
64
+ let warmupCompleted = false;
65
+ let warmupDurationMs: number | undefined;
66
+ let warmupError: string | undefined;
67
+
68
+ /**
69
+ * Start the runtime warmup (idempotent). Fires eager `import()` of the hot
70
+ * module graph. Safe to call during single-threaded registration — the
71
+ * promises resolve on the event loop before any subagent can spawn.
72
+ *
73
+ * Errors are swallowed: a failed warmup (e.g. peer dep absent) must never
74
+ * block the extension from loading. The worst case is the old race returns
75
+ * (which is no worse than before this fix).
76
+ */
77
+ export function startRuntimeWarmup(): void {
78
+ if (warmupStarted) return;
79
+ warmupStarted = true;
80
+ const startedAt = Date.now();
81
+ warmupPromise = (async (): Promise<void> => {
82
+ const imports: Array<Promise<unknown>> = [];
83
+ for (const spec of HOT_MODULE_SPECIFIERS) {
84
+ imports.push(
85
+ import(new URL(spec, import.meta.url).href).catch(() => {
86
+ // swallow — never block registration on a warmup failure
87
+ }),
88
+ );
89
+ }
90
+ for (const dep of HOT_PEER_DEPS) {
91
+ imports.push(
92
+ import(dep).catch(() => {
93
+ // peer dep may be absent (optional dep) — swallow
94
+ }),
95
+ );
96
+ }
97
+ await Promise.all(imports);
98
+ })()
99
+ .then(() => {
100
+ warmupCompleted = true;
101
+ warmupDurationMs = Date.now() - startedAt;
102
+ })
103
+ .catch((err: unknown) => {
104
+ // final safety net — warmup must never reject. Record for diagnostics.
105
+ warmupError = err instanceof Error ? err.message : String(err ?? "unknown");
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Await the runtime warmup at a spawn entry point. No-op if warmup hasn't
111
+ * started (back-compat for callers that don't call startRuntimeWarmup) or has
112
+ * already resolved. Guarantees the hot module graph is instantiated before
113
+ * the caller touches any module — eliminating the concurrent cold-start race.
114
+ */
115
+ export async function awaitRuntimeWarmup(): Promise<void> {
116
+ if (warmupPromise) await warmupPromise;
117
+ }
118
+
119
+ /**
120
+ * Test seam: reset the warmup state so tests can re-trigger it. Also lets
121
+ * tests verify idempotency by calling startRuntimeWarmup() multiple times.
122
+ */
123
+ export function resetRuntimeWarmupForTest(): void {
124
+ warmupPromise = undefined;
125
+ warmupStarted = false;
126
+ warmupCompleted = false;
127
+ warmupDurationMs = undefined;
128
+ warmupError = undefined;
129
+ }
130
+
131
+ /** Test seam: has startRuntimeWarmup() been called? */
132
+ export function isRuntimeWarmupStarted(): boolean {
133
+ return warmupStarted;
134
+ }
135
+
136
+ /**
137
+ * Diagnostic snapshot of warmup state for `team doctor`. Surfaces whether the
138
+ * v0.8.6 cold-start fix is active and how long the graph warmup took, so a
139
+ * session can confirm the fix loaded (post-restart) and isn't pathologically
140
+ * slow.
141
+ */
142
+ export interface RuntimeWarmupStatus {
143
+ started: boolean;
144
+ completed: boolean;
145
+ durationMs: number | undefined;
146
+ error: string | undefined;
147
+ }
148
+
149
+ export function getRuntimeWarmupStatus(): RuntimeWarmupStatus {
150
+ return {
151
+ started: warmupStarted,
152
+ completed: warmupCompleted,
153
+ durationMs: warmupDurationMs,
154
+ error: warmupError,
155
+ };
156
+ }
@@ -40,6 +40,7 @@ import {
40
40
  type ParsedPiJsonOutput,
41
41
  } from "./pi-json-output.ts";
42
42
  import { runChildPi, type ChildPiLifecycleEvent } from "./child-pi.ts";
43
+ import { awaitRuntimeWarmup } from "./runtime-warmup.ts";
43
44
  import { buildTaskPacket } from "./task-packet.ts";
44
45
  import { executeHook, appendHookEvent } from "../hooks/registry.ts";
45
46
  import { createVerificationEvidence } from "./green-contract.ts";
@@ -155,6 +156,10 @@ export interface TaskRunnerInput {
155
156
  export async function runTeamTask(
156
157
  input: TaskRunnerInput,
157
158
  ): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
159
+ // Cold-start race fix: ensure the hot module graph is warm before touching
160
+ // any module. Under tsx, concurrent first-imports race module-record
161
+ // instantiation; awaiting the registration-time warmup eliminates the window.
162
+ await awaitRuntimeWarmup();
158
163
  let manifest = input.manifest;
159
164
  // H4: registerStreamBridge inside try so dispose() in finally is safe
160
165
  let streamBridge: ReturnType<typeof registerStreamBridge> | undefined;