pi-crew 0.8.5 → 0.8.6
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
|
@@ -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();
|
|
@@ -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,121 @@
|
|
|
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
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start the runtime warmup (idempotent). Fires eager `import()` of the hot
|
|
67
|
+
* module graph. Safe to call during single-threaded registration — the
|
|
68
|
+
* promises resolve on the event loop before any subagent can spawn.
|
|
69
|
+
*
|
|
70
|
+
* Errors are swallowed: a failed warmup (e.g. peer dep absent) must never
|
|
71
|
+
* block the extension from loading. The worst case is the old race returns
|
|
72
|
+
* (which is no worse than before this fix).
|
|
73
|
+
*/
|
|
74
|
+
export function startRuntimeWarmup(): void {
|
|
75
|
+
if (warmupStarted) return;
|
|
76
|
+
warmupStarted = true;
|
|
77
|
+
warmupPromise = (async (): Promise<void> => {
|
|
78
|
+
const imports: Array<Promise<unknown>> = [];
|
|
79
|
+
for (const spec of HOT_MODULE_SPECIFIERS) {
|
|
80
|
+
imports.push(
|
|
81
|
+
import(new URL(spec, import.meta.url).href).catch(() => {
|
|
82
|
+
// swallow — never block registration on a warmup failure
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
for (const dep of HOT_PEER_DEPS) {
|
|
87
|
+
imports.push(
|
|
88
|
+
import(dep).catch(() => {
|
|
89
|
+
// peer dep may be absent (optional dep) — swallow
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
await Promise.all(imports);
|
|
94
|
+
})().catch(() => {
|
|
95
|
+
// final safety net — warmup must never reject
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Await the runtime warmup at a spawn entry point. No-op if warmup hasn't
|
|
101
|
+
* started (back-compat for callers that don't call startRuntimeWarmup) or has
|
|
102
|
+
* already resolved. Guarantees the hot module graph is instantiated before
|
|
103
|
+
* the caller touches any module — eliminating the concurrent cold-start race.
|
|
104
|
+
*/
|
|
105
|
+
export async function awaitRuntimeWarmup(): Promise<void> {
|
|
106
|
+
if (warmupPromise) await warmupPromise;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Test seam: reset the warmup state so tests can re-trigger it. Also lets
|
|
111
|
+
* tests verify idempotency by calling startRuntimeWarmup() multiple times.
|
|
112
|
+
*/
|
|
113
|
+
export function resetRuntimeWarmupForTest(): void {
|
|
114
|
+
warmupPromise = undefined;
|
|
115
|
+
warmupStarted = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test seam: has startRuntimeWarmup() been called? */
|
|
119
|
+
export function isRuntimeWarmupStarted(): boolean {
|
|
120
|
+
return warmupStarted;
|
|
121
|
+
}
|
|
@@ -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;
|