pi-crew 0.8.4 → 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 +100 -0
- package/package.json +1 -1
- package/src/config/types.ts +9 -0
- package/src/extension/register.ts +31 -0
- package/src/runtime/live-session-runtime.ts +5 -0
- package/src/runtime/per-write-validator.ts +183 -0
- package/src/runtime/runtime-warmup.ts +121 -0
- package/src/runtime/task-runner.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
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
|
+
|
|
52
|
+
## [0.8.5] — Per-write validator (T5) + validateWorkflowForTeam race note (2026-06-16)
|
|
53
|
+
|
|
54
|
+
Third APPLIED technique from the pi-ecosystem distillation (pi-lens /
|
|
55
|
+
apmantza — the "inline channel"). Adds real-time feedback on file
|
|
56
|
+
writes/edits: a CHEAP synchronous validator runs on every `write`/`edit`
|
|
57
|
+
tool result and appends a `🔴` blocker to the tool result on failure, so
|
|
58
|
+
malformed files are caught the moment they're written — not at the next
|
|
59
|
+
load.
|
|
60
|
+
|
|
61
|
+
### Latency-safe v1 design (deliberate scope)
|
|
62
|
+
|
|
63
|
+
pi-lens runs LSP servers + linters per write. That is expensive and would
|
|
64
|
+
cause latency storms if naively ported (seconds of spawn per edit, firing in
|
|
65
|
+
the main session AND every worker). This v1 ships ONLY zero-cost, zero-spawn,
|
|
66
|
+
synchronous validators:
|
|
67
|
+
|
|
68
|
+
- **`json` → `JSON.parse`** (nanoseconds, built-in, no process spawn).
|
|
69
|
+
|
|
70
|
+
The registry is extensible — process-spawning validators (`.js` → `node
|
|
71
|
+
--check`, `.sh` → `bash -n`, `.py` → `py_compile`) are a FUTURE opt-in
|
|
72
|
+
(never default-on), and will need to be async + debounced (pi-lens's
|
|
73
|
+
`inFlightPipelines` / debounce-window pattern) when added.
|
|
74
|
+
|
|
75
|
+
### Contract guarantees
|
|
76
|
+
- Synchronous. No `await`, no `spawn`, no disk write.
|
|
77
|
+
- One disk READ per validated file (after a cheap extension check, so
|
|
78
|
+
non-validated files cost nothing).
|
|
79
|
+
- Dedup by content: the same path+content is validated at most once per
|
|
80
|
+
process.
|
|
81
|
+
- Silent on success; appends exactly one TextContent block on failure.
|
|
82
|
+
- Best-effort: any internal error is swallowed (never breaks a write).
|
|
83
|
+
- Toggle: `runtime.reliability.perWriteValidation` (default `true` → opt-out).
|
|
84
|
+
|
|
85
|
+
### Files
|
|
86
|
+
- NEW `src/runtime/per-write-validator.ts` — `validateJson`, the extensible
|
|
87
|
+
`PerWriteValidator` registry, dedup cache, `validateWrittenFile`, and
|
|
88
|
+
`buildValidationBlocker`. Test seams: `setPerWriteValidatorsForTest`,
|
|
89
|
+
`resetPerWriteValidatorCache`.
|
|
90
|
+
- `src/config/types.ts` — `reliability.perWriteValidation?: boolean`.
|
|
91
|
+
- `src/extension/register.ts` — `pi.on("tool_result", ...)` handler for
|
|
92
|
+
`write`/`edit` (pi-crew previously subscribed only to `tool_call`).
|
|
93
|
+
- NEW `test/unit/t5-per-write-validator.test.ts` (15 tests).
|
|
94
|
+
- NEW `.github/issues/2026-06-16-validateworkflowf-team-cold-start-race.md` —
|
|
95
|
+
honest note that the `validateWorkflowForTeam` cold-start error (same
|
|
96
|
+
class as v0.8.1's `existsSync`) was NOT actually fixed by v0.8.1's latch
|
|
97
|
+
(that covered only the peer-dep namespace). Documents the corrected
|
|
98
|
+
root cause (tsx makes every named import a runtime namespace access) and
|
|
99
|
+
4 candidate fixes for the later pass.
|
|
100
|
+
|
|
101
|
+
typecheck clean; full suite 0 failures.
|
|
102
|
+
|
|
3
103
|
## [0.8.4] — cold-verifier agent (T9) (2026-06-16)
|
|
4
104
|
|
|
5
105
|
Second APPLIED technique from the pi-ecosystem distillation (piolium /
|
package/package.json
CHANGED
package/src/config/types.ts
CHANGED
|
@@ -180,6 +180,15 @@ export interface CrewReliabilityConfig {
|
|
|
180
180
|
cleanupOrphanedTempDirs?: boolean;
|
|
181
181
|
/** Inject a compact ambient crew-status note into the agent's context on every LLM call while crew runs are in-flight, so the agent stays continuously aware of active runs without calling the `team` tool. No-op when no runs are active. Default: true. */
|
|
182
182
|
ambientStatusInjection?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Per-write validation (T5). On every `write`/`edit` tool result, run a
|
|
185
|
+
* zero-cost synchronous validator for the file type and append a `🔴`
|
|
186
|
+
* blocker to the tool result on failure (e.g. malformed JSON). v1 ships
|
|
187
|
+
* JSON only (`JSON.parse` — instant, no process spawn); process-spawning
|
|
188
|
+
* validators (.js/.sh/.py) are a future opt-in. Default: true (opt-out).
|
|
189
|
+
* Set to `false` to disable.
|
|
190
|
+
*/
|
|
191
|
+
perWriteValidation?: boolean;
|
|
183
192
|
/**
|
|
184
193
|
* Opt-in model scope enforcement (F7). When true, subagent model choices
|
|
185
194
|
* that fall outside the user's pi `enabledModels` allowlist are flagged:
|
|
@@ -82,6 +82,8 @@ import {
|
|
|
82
82
|
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
|
+
import { extractPathFromInput, validateWrittenFile, buildValidationBlocker } from "../runtime/per-write-validator.ts";
|
|
86
|
+
import { startRuntimeWarmup } from "../runtime/runtime-warmup.ts";
|
|
85
87
|
import { createRunSnapshotCache } from "../ui/run-snapshot-cache.ts";
|
|
86
88
|
import { closeWatcher } from "../utils/fs-watch.ts";
|
|
87
89
|
import { RunWatcherRegistry } from "../utils/run-watcher-registry.ts";
|
|
@@ -197,6 +199,14 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
197
199
|
const disposeI18n = initI18n(pi);
|
|
198
200
|
resetTimings();
|
|
199
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();
|
|
200
210
|
// Deploy bundled themes (crew-dark, crew-dracula, etc.) to ~/.pi/agent/themes/
|
|
201
211
|
// so Pi's theme loader discovers them. Best-effort, idempotent.
|
|
202
212
|
deployBundledThemes();
|
|
@@ -1986,6 +1996,27 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
1986
1996
|
};
|
|
1987
1997
|
});
|
|
1988
1998
|
|
|
1999
|
+
// T5 (v0.8.5): per-write validation. On write/edit, run a zero-cost
|
|
2000
|
+
// SYNCHRONOUS validator (v1: JSON.parse) and append a 🔴 blocker to the
|
|
2001
|
+
// tool result on failure — catches malformed config the moment it's
|
|
2002
|
+
// written, not at the next load. Latency-safe by construction: no process
|
|
2003
|
+
// spawn, one disk read ONLY for validated extensions, dedup'd by content.
|
|
2004
|
+
// Toggle via runtime.reliability.perWriteValidation (default true).
|
|
2005
|
+
// Process-spawning validators (.js/.sh/.py) are a future opt-in.
|
|
2006
|
+
pi.on("tool_result", (event, ctx) => {
|
|
2007
|
+
try {
|
|
2008
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
2009
|
+
if (loadConfig(ctx.cwd).config.reliability?.perWriteValidation === false) return;
|
|
2010
|
+
const filePath = extractPathFromInput(event.input);
|
|
2011
|
+
if (!filePath) return;
|
|
2012
|
+
const result = validateWrittenFile(filePath);
|
|
2013
|
+
if (!result || result.ok) return;
|
|
2014
|
+
return { content: [...event.content, buildValidationBlocker(filePath, result.error ?? "validation failed")] };
|
|
2015
|
+
} catch {
|
|
2016
|
+
// best-effort: never break a tool result
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
|
|
1989
2020
|
registerTeamTool(pi, {
|
|
1990
2021
|
foregroundControllers,
|
|
1991
2022
|
startForegroundRun,
|
|
@@ -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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-write validator — real-time feedback on file writes/edits (T5).
|
|
3
|
+
*
|
|
4
|
+
* Distilled from pi-lens (apmantza) — the "inline channel": on every
|
|
5
|
+
* `write`/`edit` tool result, run a CHEAP synchronous validator for the file
|
|
6
|
+
* type and, on failure, append a `🔴` blocker block to the tool result the
|
|
7
|
+
* agent sees next. This catches silent-breaking errors (malformed JSON
|
|
8
|
+
* config) at the moment they're introduced instead of at the next load.
|
|
9
|
+
*
|
|
10
|
+
* CRITICAL LATENCY-SAFETY DESIGN (the reason this is a careful slice, not the
|
|
11
|
+
* full pi-lens pipeline): pi-lens runs LSP servers + linters per write. That
|
|
12
|
+
* is expensive and would cause latency storms if naively ported (seconds of
|
|
13
|
+
* spawn per edit, firing in the main session AND every worker). This module's
|
|
14
|
+
* v1 deliberately ships ONLY zero-cost, zero-spawn, synchronous validators:
|
|
15
|
+
*
|
|
16
|
+
* - `json` → `JSON.parse` (nanoseconds, built-in, no process spawn).
|
|
17
|
+
*
|
|
18
|
+
* The registry is extensible — future validators (`.js` → `node --check`,
|
|
19
|
+
* `.sh` → `bash -n`, `.py` → `py_compile`) are process-spawning and MUST be
|
|
20
|
+
* added behind an explicit opt-in (never default-on) to preserve the
|
|
21
|
+
* latency guarantee. A process-spawning validator would also need to be async
|
|
22
|
+
* and debounced (pi-lens's `inFlightPipelines` / debounce-window pattern),
|
|
23
|
+
* which the current sync contract intentionally avoids.
|
|
24
|
+
*
|
|
25
|
+
* Contract guarantees for v1:
|
|
26
|
+
* - Synchronous. No `await`, no `spawn`, no disk write.
|
|
27
|
+
* - One disk READ per validated file (after a cheap extension check, so
|
|
28
|
+
* non-validated files cost nothing).
|
|
29
|
+
* - Dedup by content: the same path+content is validated at most once per
|
|
30
|
+
* process (a repeated identical write doesn't re-report).
|
|
31
|
+
* - Silent on success; appends exactly one TextContent block on failure.
|
|
32
|
+
* - Best-effort: any internal error is swallowed (never breaks a write).
|
|
33
|
+
*
|
|
34
|
+
* @module per-write-validator
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readFileSync } from "node:fs";
|
|
38
|
+
import { extname as pathExtname } from "node:path";
|
|
39
|
+
|
|
40
|
+
/** Outcome of validating a file's content. */
|
|
41
|
+
export interface ValidationResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
/** Human-readable error message when `ok` is false. */
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A synchronous validator: content + path → result. */
|
|
48
|
+
export type PerWriteValidator = (content: string, filePath: string) => ValidationResult;
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// Validators (zero-cost, synchronous, dependency-free for v1)
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/** JSON: parse with `JSON.parse`. Catches malformed config/manifests instantly. */
|
|
55
|
+
export function validateJson(content: string, _filePath: string): ValidationResult {
|
|
56
|
+
if (content.trim() === "") return { ok: true }; // empty file is valid JSON absence, not a parse error
|
|
57
|
+
try {
|
|
58
|
+
JSON.parse(content);
|
|
59
|
+
return { ok: true };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
62
|
+
return { ok: false, error: `Invalid JSON: ${message}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Registry of default-on validators, keyed by extension (lowercase, no dot).
|
|
68
|
+
* ONLY zero-cost synchronous validators belong here. Process-spawning
|
|
69
|
+
* validators must be registered via a future opt-in path (see module doc).
|
|
70
|
+
*/
|
|
71
|
+
const DEFAULT_VALIDATORS: ReadonlyMap<string, PerWriteValidator> = new Map([
|
|
72
|
+
["json", validateJson],
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Dedup cache (path → last-validated content). Bounded; small.
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const MAX_DEDUP_ENTRIES = 256;
|
|
80
|
+
const seenContent = new Map<string, string>();
|
|
81
|
+
|
|
82
|
+
function rememberSeen(path: string, content: string): void {
|
|
83
|
+
if (seenContent.has(path)) seenContent.delete(path); // refresh LRU position
|
|
84
|
+
seenContent.set(path, content);
|
|
85
|
+
while (seenContent.size > MAX_DEDUP_ENTRIES) {
|
|
86
|
+
const oldest = seenContent.keys().next().value;
|
|
87
|
+
if (oldest === undefined) break;
|
|
88
|
+
seenContent.delete(oldest);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Test seam: reset the dedup cache between tests. */
|
|
93
|
+
export function resetPerWriteValidatorCache(): void {
|
|
94
|
+
seenContent.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Replace the validator registry (test seam). Production uses
|
|
99
|
+
* DEFAULT_VALIDATORS; tests inject a custom map to exercise specific extensions.
|
|
100
|
+
*/
|
|
101
|
+
let validators: ReadonlyMap<string, PerWriteValidator> = DEFAULT_VALIDATORS;
|
|
102
|
+
|
|
103
|
+
export function setPerWriteValidatorsForTest(map: ReadonlyMap<string, PerWriteValidator> | undefined): void {
|
|
104
|
+
validators = map ?? DEFAULT_VALIDATORS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalise an extension to the registry key form (lowercase, no leading dot).
|
|
109
|
+
* "" for files with no extension.
|
|
110
|
+
*/
|
|
111
|
+
export function extensionKey(filePath: string): string {
|
|
112
|
+
return pathExtname(filePath).replace(/^\./, "").toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
116
|
+
// Path extraction from a tool_result event input (defensive — pi-ai types
|
|
117
|
+
// aren't exported here, so accept a record and probe common field names).
|
|
118
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const PATH_FIELDS = ["filePath", "path", "file"] as const;
|
|
121
|
+
|
|
122
|
+
/** Extract the written/edited path from a tool result input, if present. */
|
|
123
|
+
export function extractPathFromInput(input: unknown): string | undefined {
|
|
124
|
+
if (!input || typeof input !== "object") return undefined;
|
|
125
|
+
const record = input as Record<string, unknown>;
|
|
126
|
+
for (const field of PATH_FIELDS) {
|
|
127
|
+
const value = record[field];
|
|
128
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// Core entry point
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validate a just-written/edited file. Returns `null` when there is nothing
|
|
139
|
+
* to report (no validator for the extension, dedup hit, file unreadable, or
|
|
140
|
+
* the content is valid). Returns a `ValidationResult` with `ok:false` when the
|
|
141
|
+
* content fails validation.
|
|
142
|
+
*
|
|
143
|
+
* Reads the file from disk (it's already written by `tool_result` time) so the
|
|
144
|
+
* logic is uniform across `write` (full content) and `edit` (patch). The disk
|
|
145
|
+
* read happens ONLY after a cheap extension check, so non-validated files cost
|
|
146
|
+
* nothing.
|
|
147
|
+
*/
|
|
148
|
+
export function validateWrittenFile(filePath: string): ValidationResult | null {
|
|
149
|
+
const key = extensionKey(filePath);
|
|
150
|
+
const validator = validators.get(key);
|
|
151
|
+
if (!validator) return null; // cheap skip: no validator for this file type
|
|
152
|
+
let content: string;
|
|
153
|
+
try {
|
|
154
|
+
content = readFileSync(filePath, "utf-8");
|
|
155
|
+
} catch {
|
|
156
|
+
// Unreadable / missing / permission denied — can't validate; never block.
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
// Dedup: identical content already validated this process → don't re-report.
|
|
160
|
+
if (seenContent.get(filePath) === content) return null;
|
|
161
|
+
rememberSeen(filePath, content);
|
|
162
|
+
const result = validator(content, filePath);
|
|
163
|
+
return result.ok ? null : result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build the TextContent block to append to a tool_result on validation failure.
|
|
168
|
+
* Uses a strong `🔴` prefix so the agent treats it as a real signal and fixes
|
|
169
|
+
* the file before continuing.
|
|
170
|
+
*/
|
|
171
|
+
export function buildValidationBlocker(filePath: string, error: string): { type: "text"; text: string } {
|
|
172
|
+
return {
|
|
173
|
+
type: "text",
|
|
174
|
+
text: [
|
|
175
|
+
"",
|
|
176
|
+
"🔴 pi-crew per-write check FAILED",
|
|
177
|
+
` ${filePath}`,
|
|
178
|
+
` ${error}`,
|
|
179
|
+
" The file you just wrote is malformed. Fix it now — a broken file here will",
|
|
180
|
+
" silently fail the next load/parse. Re-write the file with valid content before continuing.",
|
|
181
|
+
].join("\n"),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -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;
|