pi-crew 0.9.66 → 0.9.68
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 +109 -0
- package/README.md +2 -3
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/dist/index.mjs +4451 -4092
- package/package.json +12 -8
- package/scripts/analyze-run.mjs +1333 -0
- package/scripts/resource-sampler.mjs +482 -0
- package/skills/real-test-pi-crew/SKILL.md +10 -9
- package/src/agents/agent-config.ts +35 -14
- package/src/config/role-tools.ts +2 -2
- package/src/extension/knowledge-injection.ts +17 -0
- package/src/extension/pi-api.ts +0 -16
- package/src/extension/team-tool/api/agent-control.ts +358 -0
- package/src/extension/team-tool/api/handler-context.ts +57 -0
- package/src/extension/team-tool/api/heartbeat.ts +75 -0
- package/src/extension/team-tool/api/mailbox.ts +242 -0
- package/src/extension/team-tool/api/plan-approval.ts +190 -0
- package/src/extension/team-tool/api/read.ts +443 -0
- package/src/extension/team-tool/api/task-claims.ts +207 -0
- package/src/extension/team-tool/api.ts +56 -1301
- package/src/extension/team-tool/cancel.ts +15 -4
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +4 -4
- package/src/extension/team-tool/lifecycle-actions.ts +5 -5
- package/src/extension/team-tool/parallel-dispatch.ts +2 -2
- package/src/extension/team-tool/respond.ts +11 -3
- package/src/extension/team-tool/run-intent.ts +357 -0
- package/src/extension/team-tool/run.ts +8 -285
- package/src/extension/team-tool/status.ts +56 -21
- package/src/extension/team-tool.ts +10 -8
- package/src/prompt/scratchpad-lifecycle.ts +80 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
- package/src/runtime/child-pi/child-pi.ts +22 -144
- package/src/runtime/child-pi/mock-fixtures.ts +171 -0
- package/src/runtime/crew-agent-records.ts +18 -1
- package/src/runtime/scratchpad/README.md +11 -0
- package/src/runtime/scratchpad/guest.ts +157 -7
- package/src/runtime/scratchpad/protocol.ts +13 -1
- package/src/runtime/scratchpad/transform.ts +206 -12
- package/src/runtime/supervisor-contact.ts +0 -16
- package/src/runtime/task-runner/child-executor.ts +1 -0
- package/src/runtime/task-runner/state-helpers.ts +9 -1
- package/src/runtime/team-runner.ts +39 -2
- package/src/state/contracts.ts +3 -0
- package/src/state/event-log/event-log.ts +19 -27
- package/src/state/gitignore-manager.ts +61 -7
- package/src/utils/glob-match.ts +29 -0
- package/types/dwf.d.ts +1 -1
- package/src/runtime/scratchpad/snapshot-hmac.ts +0 -161
- package/src/types/new-api-types.ts +0 -35
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { execFile } from "node:child_process";
|
|
29
30
|
import { writeSync } from "node:fs";
|
|
30
31
|
import { createInterface } from "node:readline";
|
|
31
32
|
import { format, inspect } from "node:util";
|
|
@@ -179,14 +180,107 @@ console.error = consoleErr;
|
|
|
179
180
|
console.trace = consoleErr;
|
|
180
181
|
|
|
181
182
|
// ── bootstrap bindings ───────────────────────────────────────────────────────
|
|
182
|
-
// pi-rlm mounts rlm/tools/Bun handles here.
|
|
183
|
-
//
|
|
184
|
-
//
|
|
183
|
+
// pi-rlm mounts rlm/tools/Bun handles here. Pattern 12 (shell guard): the
|
|
184
|
+
// guest-local `sh` helper narrows raw child_process — it refuses null/undefined
|
|
185
|
+
// arguments (the `rm -rf undefined` class bug) and returns a value, not a
|
|
186
|
+
// string, so cell 2 can reuse cell 1's result. Advisory guard only — cells run
|
|
187
|
+
// at full worker trust (no VM sandbox), so `sh` is a nudge, not a boundary.
|
|
185
188
|
|
|
186
189
|
const INTERNAL_BINDINGS = new Map<string, unknown>();
|
|
187
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Protected Node globals — must never be persistently shadowed by a cell.
|
|
193
|
+
*
|
|
194
|
+
* Because the scope proxy's `get` trap checks the namespace BEFORE globalThis,
|
|
195
|
+
* a cell writing `const process = 'poisoned'` (transformed to a namespace
|
|
196
|
+
* assignment) would otherwise poison EVERY later cell in the same engine
|
|
197
|
+
* (the namespace is a module-level singleton) AND survive snapshot→restore
|
|
198
|
+
* (a serializable shadow revives). Result: silent corruption — e.g.
|
|
199
|
+
* `typeof process.env` becomes 'undefined' with no error.
|
|
200
|
+
*
|
|
201
|
+
* We register the LIVE global for each protected name in INTERNAL_BINDINGS +
|
|
202
|
+
* the namespace, so (a) restore's re-install overwrites any revived shadow,
|
|
203
|
+
* and (b) each cell starts with the real global. Within-cell shadowing still
|
|
204
|
+
* works (the cell's own write wins for that cell only); it just cannot leak
|
|
205
|
+
* to other cells or across restarts. Verified: probe reproduced the bug
|
|
206
|
+
* pre-fix; see test/unit/runtime/scratchpad/guest-global-shadow.test.ts.
|
|
207
|
+
*/
|
|
208
|
+
const PROTECTED_GLOBALS: ReadonlyArray<string> = [
|
|
209
|
+
"process",
|
|
210
|
+
"Buffer",
|
|
211
|
+
"console",
|
|
212
|
+
"setTimeout",
|
|
213
|
+
"clearTimeout",
|
|
214
|
+
"setInterval",
|
|
215
|
+
"clearInterval",
|
|
216
|
+
"setImmediate",
|
|
217
|
+
"clearImmediate",
|
|
218
|
+
"queueMicrotask",
|
|
219
|
+
"structuredClone",
|
|
220
|
+
"AbortController",
|
|
221
|
+
"globalThis",
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
function resetProtectedGlobals(): void {
|
|
225
|
+
const g = globalThis as Record<string, unknown>;
|
|
226
|
+
for (const name of PROTECTED_GLOBALS) {
|
|
227
|
+
const live = g[name];
|
|
228
|
+
if (live === undefined) continue;
|
|
229
|
+
INTERNAL_BINDINGS.set(name, live);
|
|
230
|
+
namespace[name] = live;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Pattern-12 nullish guard: refuse undefined/null BEFORE spawning. */
|
|
235
|
+
function assertShellArgNotNullish(name: string, value: unknown): void {
|
|
236
|
+
if (value === undefined || value === null) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`sh: argument '${name}' is null/undefined — a missing variable would stringify to the literal 'undefined' in the command. Re-verify variables after a restore before using them in shell commands.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* sh(cmd, args[]) — pattern-12 shell interpolation guard.
|
|
245
|
+
* Runs via execFile (shell:false, args as an array — no shell interpolation
|
|
246
|
+
* of the command string), refuses nullish args, and returns a value:
|
|
247
|
+
* `{ exitCode, stdout, stderr }` (pi-rlm "shell as value"). Non-zero exit does
|
|
248
|
+
* NOT throw — it is returned in `exitCode`.
|
|
249
|
+
*/
|
|
250
|
+
function makeSh(): (cmd: string, args?: unknown[]) => Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
251
|
+
const execFileAsync = (cmd: string, args: string[]) =>
|
|
252
|
+
new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve, reject) => {
|
|
253
|
+
execFile(cmd, args, { shell: false, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
254
|
+
if (error) {
|
|
255
|
+
// error.code is the exit code for a non-zero exit; otherwise a real
|
|
256
|
+
// spawn failure (ENOENT etc.) — surface it as exitCode 127-like.
|
|
257
|
+
const exitCode = typeof error.code === "number" ? error.code : 127;
|
|
258
|
+
resolve({ exitCode, stdout: String(stdout), stderr: String(stderr || error.message) });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
resolve({ exitCode: 0, stdout: String(stdout), stderr: String(stderr) });
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
return async (cmd: string, args?: unknown[]) => {
|
|
265
|
+
assertShellArgNotNullish("cmd", cmd);
|
|
266
|
+
const stringArgs = (args ?? []).map((a, i) => {
|
|
267
|
+
assertShellArgNotNullish(`arg[${i}]`, a);
|
|
268
|
+
return String(a);
|
|
269
|
+
});
|
|
270
|
+
return execFileAsync(cmd, stringArgs);
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
188
274
|
function installBootstrapBindings(): void {
|
|
189
|
-
|
|
275
|
+
const sh = makeSh();
|
|
276
|
+
// Register the SAME function reference in INTERNAL_BINDINGS and the
|
|
277
|
+
// namespace so snapshotNamespace()'s identity skip (===) excludes it from
|
|
278
|
+
// serialization, and so cells can call `sh(...)` directly.
|
|
279
|
+
INTERNAL_BINDINGS.set("sh", sh);
|
|
280
|
+
namespace.sh = sh;
|
|
281
|
+
// Overwrite any protected-global shadow (e.g. a revived `process='x'` from
|
|
282
|
+
// restore) with the live global — fixes the global shadow poisoning bug.
|
|
283
|
+
resetProtectedGlobals();
|
|
190
284
|
}
|
|
191
285
|
|
|
192
286
|
installBootstrapBindings();
|
|
@@ -198,14 +292,69 @@ const AsyncFunction = (async () => {}).constructor as new (...args: string[]) =>
|
|
|
198
292
|
|
|
199
293
|
const liveCells = new Map<string, CellContext>();
|
|
200
294
|
|
|
295
|
+
// ── P6: remap transformed error-stack lines back to the cell's source ───────
|
|
296
|
+
// V8 reports a thrown line as `N = bodyLine + 2` (1-based) — the AsyncFunction
|
|
297
|
+
// wrapper adds 2 prefix lines (the anonymous function line + the `with (...)`
|
|
298
|
+
// line) before the cell body. `lineMap` (from transformCell) maps body lines
|
|
299
|
+
// that RECEIVED A REPLACEMENT back to the source line the model wrote. Lines
|
|
300
|
+
// between replacements (unreplaced spans — e.g. a bare `throw`) are identity:
|
|
301
|
+
// sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine), using the
|
|
302
|
+
// nearest replacement entry at or before the body line.
|
|
303
|
+
//
|
|
304
|
+
// SECURITY/robustness (P6 review finding): only lines that look like a real
|
|
305
|
+
// V8 frame (` at ... <anonymous>:N:C)`) are remapped. The `Error: <message>`
|
|
306
|
+
// line and any cell text containing `<anonymous>:N:C)` (e.g. an error message
|
|
307
|
+
// that echoes code) must be left byte-identical — a crafted message must never
|
|
308
|
+
// get its numbers rewritten.
|
|
309
|
+
function remapStackLines(stack: string, lineMap: { sourceLine: number; bodyLine: number }[]): string[] {
|
|
310
|
+
const lines = stack.split("\n");
|
|
311
|
+
const sorted = [...lineMap].sort((a, b) => a.bodyLine - b.bodyLine);
|
|
312
|
+
return lines.map((line) => {
|
|
313
|
+
// Real V8 frame only: ` at <anything> <anonymous>:N:C)` — anchored to
|
|
314
|
+
// the leading frame prefix and a parenthesized trailing position. The
|
|
315
|
+
// message line (`Error: ...`) does not start with ` at `, so it is
|
|
316
|
+
// never matched; a crafted message containing `<anonymous>:N:C)` is
|
|
317
|
+
// likewise safe because it lacks the frame prefix.
|
|
318
|
+
const m = line.match(/^\s+at .*<anonymous>:(\d+):(\d+)\)/);
|
|
319
|
+
if (!m) return line;
|
|
320
|
+
const reported = Number(m[1]);
|
|
321
|
+
const bodyLine = reported - 2; // wrapper prefix offset (verified by probe)
|
|
322
|
+
// nearest replacement entry at or before bodyLine (lower bound)
|
|
323
|
+
let lo = 0;
|
|
324
|
+
let hi = sorted.length - 1;
|
|
325
|
+
let entry: { sourceLine: number; bodyLine: number } | undefined;
|
|
326
|
+
while (lo <= hi) {
|
|
327
|
+
const mid = (lo + hi) >> 1;
|
|
328
|
+
if (sorted[mid].bodyLine <= bodyLine) {
|
|
329
|
+
entry = sorted[mid];
|
|
330
|
+
lo = mid + 1;
|
|
331
|
+
} else {
|
|
332
|
+
hi = mid - 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!entry) return line; // before any replacement — leave as-is
|
|
336
|
+
const sourceLine = entry.sourceLine + (bodyLine - entry.bodyLine);
|
|
337
|
+
const col = m[2];
|
|
338
|
+
return line.replace(/<anonymous>:\d+:\d+/, `<anonymous>:${sourceLine}:${col}`);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
201
342
|
async function runCell(cellId: string, code: string): Promise<void> {
|
|
202
343
|
const ctx = makeCellContext(cellId);
|
|
203
344
|
activeCell = ctx;
|
|
204
345
|
liveCells.set(cellId, ctx);
|
|
346
|
+
// Reset protected globals so a prior cell's shadow (e.g. `const process='x'`)
|
|
347
|
+
// cannot poison this cell — shadowing stays local to the cell that did it
|
|
348
|
+
// (the namespace is a module-level singleton shared across cells).
|
|
349
|
+
resetProtectedGlobals();
|
|
205
350
|
|
|
206
351
|
let done: GuestToHostMessage;
|
|
352
|
+
// P6: lineMap from transformCell is needed in BOTH try (to build the
|
|
353
|
+
// wrapper) and catch (to remap the error stack), so hoist it here.
|
|
354
|
+
let lineMap: { sourceLine: number; bodyLine: number }[] = [];
|
|
207
355
|
try {
|
|
208
|
-
const { body } = transformCell(code, { ctxName: CTX_NAME });
|
|
356
|
+
const { body, lineMap: lm } = transformCell(code, { ctxName: CTX_NAME });
|
|
357
|
+
lineMap = lm;
|
|
209
358
|
// Sloppy-mode wrapper so `with` is legal (AsyncFunction bodies are always
|
|
210
359
|
// sloppy, even inside a strict ESM strip-types module); async for
|
|
211
360
|
// top-level await.
|
|
@@ -223,7 +372,7 @@ async function runCell(cellId: string, code: string): Promise<void> {
|
|
|
223
372
|
type: "done",
|
|
224
373
|
cellId,
|
|
225
374
|
status: ctx.aborted ? "aborted" : "error",
|
|
226
|
-
error: { name: err.name, message: err.message, stack: (err.stack ?? "")
|
|
375
|
+
error: { name: err.name, message: err.message, stack: remapStackLines(err.stack ?? "", lineMap) },
|
|
227
376
|
};
|
|
228
377
|
} finally {
|
|
229
378
|
if (activeCell === ctx) activeCell = undefined;
|
|
@@ -294,7 +443,8 @@ function restoreNamespace(vars: Record<string, string>): {
|
|
|
294
443
|
}
|
|
295
444
|
}
|
|
296
445
|
// Bootstrap runs after restore: live handles would overwrite anything
|
|
297
|
-
// revived.
|
|
446
|
+
// revived. `sh` is re-installed so a revived stale value cannot shadow the
|
|
447
|
+
// live handle (I6).
|
|
298
448
|
installBootstrapBindings();
|
|
299
449
|
return { restored, failed };
|
|
300
450
|
}
|
|
@@ -45,7 +45,19 @@ export interface GuestToHost {
|
|
|
45
45
|
error?: { name: string; message: string; stack: string[] };
|
|
46
46
|
};
|
|
47
47
|
pong: { type: "pong"; id: string };
|
|
48
|
-
host_request: {
|
|
48
|
+
host_request: {
|
|
49
|
+
// RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
|
|
50
|
+
// J2): guest-side cells would request host services (tools.read,
|
|
51
|
+
// tools.grep) so data enters the namespace WITHOUT crossing the
|
|
52
|
+
// transcript — pi-rlm's core token-saving value proposition. Declared
|
|
53
|
+
// here so the protocol type is stable, but NOT YET WIRED: engine.ts and
|
|
54
|
+
// guest.ts have no host_request dispatcher/handler. Blocked on scratchpad
|
|
55
|
+
// adoption > 0 (do not add a host execution surface nobody calls).
|
|
56
|
+
type: "host_request";
|
|
57
|
+
id: string;
|
|
58
|
+
requestType: string;
|
|
59
|
+
payload: Record<string, unknown>;
|
|
60
|
+
};
|
|
49
61
|
snapshot_result: {
|
|
50
62
|
type: "snapshot_result";
|
|
51
63
|
id: string;
|
|
@@ -31,6 +31,15 @@ export interface TransformedCell {
|
|
|
31
31
|
body: string;
|
|
32
32
|
/** Top-level names this cell binds into the namespace. */
|
|
33
33
|
declaredNames: string[];
|
|
34
|
+
/**
|
|
35
|
+
* P6: line-based position map — for each body line (1-based), the
|
|
36
|
+
* corresponding SOURCE line (1-based) the model wrote. Used to remap the
|
|
37
|
+
* V8-reported error line (relative to `body`) back to the cell's original
|
|
38
|
+
* line so stack traces point at the source the user wrote, not the
|
|
39
|
+
* transformed body. Built WITHOUT a source-map dependency by tracking
|
|
40
|
+
* replacement newline shifts against the source (acorn `locations`).
|
|
41
|
+
*/
|
|
42
|
+
lineMap: { sourceLine: number; bodyLine: number }[];
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface TransformOptions {
|
|
@@ -41,8 +50,16 @@ export interface TransformOptions {
|
|
|
41
50
|
// esbuild's transform strips types but never drops side-effect-free trailing
|
|
42
51
|
// expressions (no DCE in transform mode) — the exact thing we capture as the
|
|
43
52
|
// cell result.
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
// P6: `sourcemap: 'inline'` so we can map the stripped `js` lines back to the
|
|
54
|
+
// `rewritten` (pre-strip) lines — esbuild does NOT preserve line numbers
|
|
55
|
+
// across type stripping (probe: 7 input lines → 5 output lines). The inline
|
|
56
|
+
// sourcemap is stripped from the output (it would otherwise leak into the cell
|
|
57
|
+
// body as a trailing comment); we only keep the decoded line map.
|
|
58
|
+
function stripTypes(code: string): { code: string; lineMap: number[] } {
|
|
59
|
+
const out = transformSync(code, { loader: "ts", sourcemap: "inline" });
|
|
60
|
+
const lineMap = esbuildLineMap(out.code);
|
|
61
|
+
const codeWithoutMap = out.code.replace(/\/\/# sourceMappingURL=data:application\/json;base64,[A-Za-z0-9+/=]+\s*$/, "");
|
|
62
|
+
return { code: codeWithoutMap, lineMap };
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
// ── top-level import extraction ──────────────────────────────────────────────
|
|
@@ -271,14 +288,147 @@ function variableReplacement(decl: VariableDeclaration, source: string): string
|
|
|
271
288
|
return statements.join(" ");
|
|
272
289
|
}
|
|
273
290
|
|
|
291
|
+
// ── P6: line-based position map ─────────────────────────────────────────────
|
|
292
|
+
// V8 reports a cell error's line as 1-based relative to the TRANSFORMED `body`.
|
|
293
|
+
// The model wrote `code`; between them sit 3 transforms:
|
|
294
|
+
// 1. import pre-rewrite (acorn) — collapses multi-line imports to 1 line
|
|
295
|
+
// 2. esbuild strip-types — collapses type annotations/signatures
|
|
296
|
+
// 3. declaration → assignment + trailing-expr capture — may add/remove lines
|
|
297
|
+
// We build a map { sourceLine (1-based, in `code`) → bodyLine (1-based) } by
|
|
298
|
+
// tracking line deltas across each stage with acorn `locations` + newline
|
|
299
|
+
// counts. No source-map dependency (probe: esbuild strip-types does NOT
|
|
300
|
+
// preserve lines — 7→5 — so a naive single-layer sourcemap would be wrong).
|
|
301
|
+
|
|
302
|
+
function countNewlines(s: string): number {
|
|
303
|
+
let n = 0;
|
|
304
|
+
for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) === 10) n++;
|
|
305
|
+
return n;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 1-based line of `pos` (char offset) in `text`. */
|
|
309
|
+
function lineAt(text: string, pos: number): number {
|
|
310
|
+
let line = 1;
|
|
311
|
+
for (let i = 0; i < pos && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
|
312
|
+
return line;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── P6: esbuild inline sourcemap → line map (js → rewritten) ───────────────
|
|
316
|
+
// esbuild `sourcemap: 'inline'` emits a base64 VLQ sourcemap whose `mappings`
|
|
317
|
+
// field encodes, per generated line, segments mapping back to source
|
|
318
|
+
// positions. We only need LINE fidelity (column is overkill for the bug), so
|
|
319
|
+
// we decode each generated line's first segment's source line. VLQ is the
|
|
320
|
+
// standard base64-variable-length encoding (no dependency needed).
|
|
321
|
+
|
|
322
|
+
const BASE64: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
323
|
+
const VLQ: Map<string, number> = new Map([...BASE64].map((c, i) => [c, i]));
|
|
324
|
+
|
|
325
|
+
function decodeVlqSegment(segment: string): number[] {
|
|
326
|
+
const values: number[] = [];
|
|
327
|
+
let shift = 0;
|
|
328
|
+
let value = 0;
|
|
329
|
+
for (let i = 0; i < segment.length; i++) {
|
|
330
|
+
const digit = VLQ.get(segment[i]);
|
|
331
|
+
if (digit === undefined) break;
|
|
332
|
+
value |= (digit & 31) << shift;
|
|
333
|
+
if (digit & 32) {
|
|
334
|
+
shift += 5;
|
|
335
|
+
} else {
|
|
336
|
+
values.push(value & 1 ? -(value >>> 1) : value >>> 1);
|
|
337
|
+
value = 0;
|
|
338
|
+
shift = 0;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return values;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Decode an esbuild inline sourcemap's `mappings` into an array where
|
|
346
|
+
* `lineMapGenerated[generatedLine-1]` = the source LINE (1-based) that
|
|
347
|
+
* generated line originates from (first segment per generated line; falls back
|
|
348
|
+
* to the previous mapped line for continuation lines).
|
|
349
|
+
*/
|
|
350
|
+
function esbuildLineMap(codeWithInlineMap: string): number[] {
|
|
351
|
+
const m = codeWithInlineMap.match(/\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/);
|
|
352
|
+
if (!m) return [];
|
|
353
|
+
let sm: { mappings?: string };
|
|
354
|
+
try {
|
|
355
|
+
sm = JSON.parse(Buffer.from(m[1], "base64").toString("utf8")) as { mappings?: string };
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
if (!sm.mappings) return [];
|
|
360
|
+
const result: number[] = [];
|
|
361
|
+
let srcLine = 1;
|
|
362
|
+
let lastSrcLine = 1;
|
|
363
|
+
for (const genLine of sm.mappings.split(";")) {
|
|
364
|
+
const segs = genLine.split(",").filter(Boolean);
|
|
365
|
+
if (segs.length > 0) {
|
|
366
|
+
const vals = decodeVlqSegment(segs[0]);
|
|
367
|
+
// values: [genColDelta, srcIdxDelta, srcLineDelta, srcColDelta, ...]
|
|
368
|
+
if (vals.length >= 3) {
|
|
369
|
+
srcLine += vals[2];
|
|
370
|
+
lastSrcLine = srcLine;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
result.push(lastSrcLine);
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build a `code`-line → `js`-line map across the import pre-rewrite.
|
|
380
|
+
* For each import replacement, its `code` span [codeStart, codeEnd] collapses
|
|
381
|
+
* to `js` lines [jsStart, jsEnd] (usually 1). Lines outside any replacement
|
|
382
|
+
* are identity-shifted by the cumulative delta.
|
|
383
|
+
*/
|
|
384
|
+
function buildImportLineMap(
|
|
385
|
+
code: string,
|
|
386
|
+
rewritten: string,
|
|
387
|
+
importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[],
|
|
388
|
+
): (src: number) => number {
|
|
389
|
+
if (importSpans.length === 0) return (src) => src;
|
|
390
|
+
// P6 (final): map a `rewritten` (post-import) LINE to the original `code`
|
|
391
|
+
// line. `rewritten` = code with each import statement replaced by its
|
|
392
|
+
// (usually 1-line) dynamic-import fragment. We walk both strings tracking
|
|
393
|
+
// newline counts: between imports the line numbers advance identically;
|
|
394
|
+
// inside an import span, all its code lines map to the replacement's first
|
|
395
|
+
// rewritten line (collapsed).
|
|
396
|
+
const rewrittenLines: string[] = rewritten.split("\n");
|
|
397
|
+
const codeLines: string[] = code.split("\n");
|
|
398
|
+
const map = new Map<number, number>(); // rewrittenLine → codeLine (1-based)
|
|
399
|
+
let codeIdx = 1;
|
|
400
|
+
let rewIdx = 1;
|
|
401
|
+
for (const span of importSpans) {
|
|
402
|
+
// Advance both to the span start, emitting identity mapping for the
|
|
403
|
+
// untouched lines between the previous span end and this span start.
|
|
404
|
+
const spanStartCodeLine = lineAt(code, span.codeStart);
|
|
405
|
+
const spanStartRewLine = lineAt(rewritten, span.jsStart);
|
|
406
|
+
for (; codeIdx < spanStartCodeLine && rewIdx < spanStartRewLine; codeIdx++, rewIdx++) {
|
|
407
|
+
map.set(rewIdx, codeIdx);
|
|
408
|
+
}
|
|
409
|
+
// Lines inside the import span (in code) collapse to the replacement's
|
|
410
|
+
// single starting rewritten line.
|
|
411
|
+
const spanEndCodeLine = lineAt(code, span.codeEnd);
|
|
412
|
+
for (; codeIdx <= spanEndCodeLine; codeIdx++) {
|
|
413
|
+
map.set(spanStartRewLine, spanStartCodeLine);
|
|
414
|
+
}
|
|
415
|
+
rewIdx = spanStartRewLine + 1;
|
|
416
|
+
}
|
|
417
|
+
// Tail: identity from wherever we stopped.
|
|
418
|
+
for (; rewIdx <= rewrittenLines.length; rewIdx++, codeIdx++) {
|
|
419
|
+
map.set(rewIdx, Math.min(codeIdx, codeLines.length));
|
|
420
|
+
}
|
|
421
|
+
return (rewLine: number) => {
|
|
422
|
+
const mapped = map.get(rewLine);
|
|
423
|
+
return mapped ?? Math.min(rewLine, codeLines.length);
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
274
427
|
export function transformCell(code: string, options: TransformOptions = {}): TransformedCell {
|
|
275
428
|
const ctxName = options.ctxName ?? "__ctx";
|
|
276
|
-
|
|
277
|
-
// Top-level imports are rewritten into awaited dynamic imports BEFORE type
|
|
278
|
-
// stripping: esbuild elides unused imports, and the reference behaviour
|
|
279
|
-
// binds every imported name into the namespace so it persists across cells.
|
|
280
429
|
let importDeclared: string[] = [];
|
|
281
430
|
let rewritten = code;
|
|
431
|
+
const importSpans: { codeStart: number; codeEnd: number; jsStart: number; jsEnd: number }[] = [];
|
|
282
432
|
try {
|
|
283
433
|
const tokens = lexTopLevel(code);
|
|
284
434
|
const imports = findImportStatements(tokens);
|
|
@@ -286,11 +436,21 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
|
|
|
286
436
|
importDeclared = [];
|
|
287
437
|
const pieces: string[] = [];
|
|
288
438
|
let cursor = 0;
|
|
439
|
+
let jsCursor = 0;
|
|
289
440
|
for (const stmt of imports) {
|
|
290
|
-
|
|
441
|
+
const pre = rewritten.slice(cursor, stmt.start);
|
|
442
|
+
pieces.push(pre);
|
|
443
|
+
jsCursor += pre.length;
|
|
291
444
|
const rewrite = rewriteImport(code, stmt, tokens);
|
|
292
445
|
importDeclared.push(...rewrite.declaredNames);
|
|
293
446
|
pieces.push(rewrite.replacement);
|
|
447
|
+
importSpans.push({
|
|
448
|
+
codeStart: stmt.start,
|
|
449
|
+
codeEnd: stmt.end,
|
|
450
|
+
jsStart: jsCursor,
|
|
451
|
+
jsEnd: jsCursor + rewrite.replacement.length - 1,
|
|
452
|
+
});
|
|
453
|
+
jsCursor += rewrite.replacement.length;
|
|
294
454
|
cursor = stmt.end;
|
|
295
455
|
}
|
|
296
456
|
pieces.push(rewritten.slice(cursor));
|
|
@@ -301,8 +461,15 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
|
|
|
301
461
|
// the pre-rewrite; type stripping below surfaces the error.
|
|
302
462
|
}
|
|
303
463
|
|
|
304
|
-
const js = stripTypes(rewritten);
|
|
305
|
-
const
|
|
464
|
+
const { code: js, lineMap: esbLineMap } = stripTypes(rewritten);
|
|
465
|
+
const rewToCode = buildImportLineMap(code, rewritten, importSpans);
|
|
466
|
+
const program: Program = parse(js, {
|
|
467
|
+
ecmaVersion: "latest",
|
|
468
|
+
sourceType: "module",
|
|
469
|
+
allowAwaitOutsideFunction: true,
|
|
470
|
+
// P6: locations needed to know each top-level node's source lines.
|
|
471
|
+
locations: true,
|
|
472
|
+
});
|
|
306
473
|
|
|
307
474
|
const declaredNames: string[] = [];
|
|
308
475
|
const replacements: { start: number; end: number; text: string }[] = [];
|
|
@@ -353,11 +520,38 @@ export function transformCell(code: string, options: TransformOptions = {}): Tra
|
|
|
353
520
|
replacements.sort((a, b) => a.start - b.start);
|
|
354
521
|
let body = "";
|
|
355
522
|
let cursor = 0;
|
|
523
|
+
// P6: build bodyLine → sourceLine map while splicing. `body` is what V8
|
|
524
|
+
// reports error lines against; `js` is the esbuild-stripped source; the map
|
|
525
|
+
// composes: bodyLine → jsLine (via newline deltas) → rewrittenLine (via
|
|
526
|
+
// esbuild inline sourcemap) → codeLine (via import pre-rewrite).
|
|
527
|
+
const lineMap: { sourceLine: number; bodyLine: number }[] = [];
|
|
528
|
+
let bodyLine = 1;
|
|
529
|
+
let jsLine = 1;
|
|
530
|
+
// jsLineOfBodyStart maps the js line at each body boundary; we track the
|
|
531
|
+
// cumulative js-line delta as body lines are emitted.
|
|
356
532
|
for (const replacement of replacements) {
|
|
357
|
-
|
|
533
|
+
const pre = js.slice(cursor, replacement.start);
|
|
534
|
+
body += pre;
|
|
535
|
+
bodyLine += countNewlines(pre);
|
|
536
|
+
jsLine += countNewlines(pre);
|
|
537
|
+
// The replacement's first body line maps from the js line at its start.
|
|
538
|
+
const repJsStartLine = lineAt(js, replacement.start);
|
|
539
|
+
// Source line = compose: js → rewritten (esbuild) → code (imports).
|
|
540
|
+
const rewLine = esbLineMap.length > 0 ? esbLineMap[Math.min(repJsStartLine - 1, esbLineMap.length - 1)] : repJsStartLine;
|
|
541
|
+
const srcLine = rewToCode(rewLine);
|
|
542
|
+
lineMap.push({ sourceLine: srcLine, bodyLine: bodyLine });
|
|
543
|
+
body += replacement.text;
|
|
544
|
+
bodyLine += countNewlines(replacement.text);
|
|
545
|
+
// js line after the replacement: replacements are in js coordinate, so
|
|
546
|
+
// jsLine advances by the replacement's text newlines too.
|
|
547
|
+
jsLine += countNewlines(replacement.text);
|
|
358
548
|
cursor = replacement.end;
|
|
359
549
|
}
|
|
360
|
-
|
|
550
|
+
const tail = js.slice(cursor);
|
|
551
|
+
body += tail;
|
|
552
|
+
bodyLine += countNewlines(tail);
|
|
553
|
+
// Any body line not covered by a replacement maps identity.
|
|
554
|
+
lineMap.sort((a, b) => a.bodyLine - b.bodyLine);
|
|
361
555
|
|
|
362
|
-
return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])] };
|
|
556
|
+
return { body, declaredNames: [...new Set([...importDeclared, ...declaredNames])], lineMap };
|
|
363
557
|
}
|
|
@@ -61,19 +61,3 @@ export function supervisorContactFromEvent(event: unknown): Omit<SupervisorConta
|
|
|
61
61
|
: undefined,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* @deprecated The compact pipeline now passes `supervisor_contact` events through
|
|
67
|
-
* to `onJsonEvent` with their full payload (P2-25 fix). Prefer
|
|
68
|
-
* `supervisorContactFromEvent` on the parsed event. Kept for backward compat.
|
|
69
|
-
*/
|
|
70
|
-
export function parseSupervisorContactFromLine(line: string): Omit<SupervisorContactPayload, "timestamp" | "runId"> | undefined {
|
|
71
|
-
if (!line.trim()) return undefined;
|
|
72
|
-
let parsed: unknown;
|
|
73
|
-
try {
|
|
74
|
-
parsed = JSON.parse(line);
|
|
75
|
-
} catch {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
return supervisorContactFromEvent(parsed);
|
|
79
|
-
}
|
|
@@ -544,6 +544,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
|
|
|
544
544
|
runId: manifest.runId,
|
|
545
545
|
agentId: task.id,
|
|
546
546
|
artifactsRoot: manifest.artifactsRoot,
|
|
547
|
+
eventsPath: manifest.eventsPath, // I5: scratchpad metric events from the worker
|
|
547
548
|
attempt: i,
|
|
548
549
|
steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
|
|
549
550
|
onSpawn: (pid) => {
|
|
@@ -41,7 +41,15 @@ export function persistSingleTaskUpdate(
|
|
|
41
41
|
checkpointPhase?: TaskCheckpointState["phase"],
|
|
42
42
|
skipCoalesce: boolean = false,
|
|
43
43
|
): TeamTaskState[] {
|
|
44
|
-
|
|
44
|
+
// H5 (2026-08-10): lowered from 100 → 10. Each retry does
|
|
45
|
+
// flushPendingAtomicWrites (global) + loadRunManifestById (stat + parse)
|
|
46
|
+
// + statSync, ~5ms each. The first attempt uses fallbackTasks directly
|
|
47
|
+
// (no disk read); retries only fire under real contention from best-effort
|
|
48
|
+
// writers that don't hold the run lock (async-notifier, crash-recovery).
|
|
49
|
+
// If 10 retries cannot converge, the system is in a pathological state
|
|
50
|
+
// where 100 would not help either — the explicit error below surfaces it
|
|
51
|
+
// instead of blocking the event loop for 500ms.
|
|
52
|
+
const MAX_CAS_ATTEMPTS = 10;
|
|
45
53
|
let baseMtime = 0;
|
|
46
54
|
try {
|
|
47
55
|
baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
appendEventBuffered,
|
|
18
18
|
appendEventFireAndForget,
|
|
19
19
|
flushEventLogBuffer,
|
|
20
|
+
readEvents,
|
|
20
21
|
} from "../state/event-log/event-log.ts";
|
|
21
22
|
import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
|
|
22
23
|
import { HealthStore } from "../state/stores/health-store.ts";
|
|
@@ -415,6 +416,27 @@ function runEffectivenessLines(
|
|
|
415
416
|
);
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
// I5 (plan): surface scratchpad adoption in the run summary — but ONLY when
|
|
420
|
+
// non-zero, so the 3 armed roles that never call it add no noise. Counts the
|
|
421
|
+
// metric events the workers appended to the run events log (fire-and-forget
|
|
422
|
+
// scratchpad.cell / scratchpad.restored). Silent when the feature is unused or
|
|
423
|
+
// the events path is missing (no throw).
|
|
424
|
+
function scratchpadSummaryLines(manifest: TeamRunManifest): string[] {
|
|
425
|
+
if (!manifest.eventsPath) return [];
|
|
426
|
+
const events = readEvents(manifest.eventsPath);
|
|
427
|
+
const cells = events.filter((e) => e.type === "scratchpad.cell");
|
|
428
|
+
const restores = events.filter((e) => e.type === "scratchpad.restored");
|
|
429
|
+
if (cells.length === 0 && restores.length === 0) return [];
|
|
430
|
+
return [
|
|
431
|
+
`## Scratchpad (RLM adoption) — I5 metric`,
|
|
432
|
+
`- cells executed: ${cells.length}`,
|
|
433
|
+
`- snapshot restores: ${restores.length}`,
|
|
434
|
+
...(cells.length > 0
|
|
435
|
+
? [`- total cell time: ${Math.round(cells.reduce((s, e) => s + ((e.data?.durationMs as number) ?? 0), 0))} ms`]
|
|
436
|
+
: []),
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
|
|
418
440
|
// P6 (perf): Cache the last-rendered progress content so we can skip the
|
|
419
441
|
// artifact write + redaction + atomic write + size/hash read when nothing
|
|
420
442
|
// material changed (rare between batches, but happens between idle heartbeats).
|
|
@@ -964,8 +986,22 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
964
986
|
);
|
|
965
987
|
manifest = updateRunStatus(manifest, "failed", `Unhandled error in team runner: ${message}`);
|
|
966
988
|
await saveRunManifestAsync(manifest);
|
|
967
|
-
} catch {
|
|
968
|
-
//
|
|
989
|
+
} catch (error) {
|
|
990
|
+
// H8 (2026-08-10): surface the silent failure. This is the
|
|
991
|
+
// unhandled-error recovery branch — if the state save ALSO fails
|
|
992
|
+
// (disk full, run-dir gone, permissions), the manifest stays in
|
|
993
|
+
// its pre-failure state (possibly still "running") and crash
|
|
994
|
+
// recovery will treat it as stale. Previously the failure was
|
|
995
|
+
// swallowed with no signal; now it lands in the internal-error
|
|
996
|
+
// channel so operators can see when recovery itself failed.
|
|
997
|
+
// We do NOT re-throw — the run must still be reported as failed
|
|
998
|
+
// to the caller (rejectRunPromise below) regardless of state-save
|
|
999
|
+
// outcome.
|
|
1000
|
+
logInternalError(
|
|
1001
|
+
"team-runner.recovery-save-failed",
|
|
1002
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
1003
|
+
`runId=${manifest.runId} — state may be inconsistent; crash-recovery will reconcile on next launch`,
|
|
1004
|
+
);
|
|
969
1005
|
}
|
|
970
1006
|
const result = { manifest, tasks };
|
|
971
1007
|
rejectRunPromise(manifest.runId, error instanceof Error ? error : new Error(message));
|
|
@@ -2260,6 +2296,7 @@ async function finalizeRun(ctx: SchedulerContext): Promise<{ manifest: TeamRunMa
|
|
|
2260
2296
|
"## Policy decisions",
|
|
2261
2297
|
...(manifest.policyDecisions?.length ? summarizePolicyDecisions(manifest.policyDecisions) : ["- (none)"]),
|
|
2262
2298
|
"",
|
|
2299
|
+
...scratchpadSummaryLines(manifest),
|
|
2263
2300
|
].join("\n"),
|
|
2264
2301
|
});
|
|
2265
2302
|
// Build the complete manifest BEFORE acquiring the lock so the artifacts array
|
package/src/state/contracts.ts
CHANGED
|
@@ -114,6 +114,9 @@ export const TEAM_EVENT_TYPES = [
|
|
|
114
114
|
"dwf.failed",
|
|
115
115
|
"dwf.trust_denied", // F-01: project .dwf.ts denied without PI_CREW_TRUST_PROJECT_DWF
|
|
116
116
|
"dwf.log",
|
|
117
|
+
// RLM/scratchpad adoption metrics (plan I5)
|
|
118
|
+
"scratchpad.cell",
|
|
119
|
+
"scratchpad.restored",
|
|
117
120
|
] as const;
|
|
118
121
|
export type TeamEventType = (typeof TEAM_EVENT_TYPES)[number];
|
|
119
122
|
|