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
|
@@ -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;
|