pi-crew 0.9.62 → 0.9.64
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 +92 -0
- package/README.md +1 -0
- package/agents/critic.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +162 -67
- package/package.json +5 -2
- package/src/agents/agent-config.ts +4 -0
- package/src/agents/agent-serializer.ts +1 -0
- package/src/agents/discover-agents.ts +8 -0
- package/src/config/role-tools.ts +49 -1
- package/src/prompt/prompt-runtime.ts +6 -0
- package/src/prompt/scratchpad-lifecycle.ts +605 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +42 -1
- package/src/runtime/child-pi/child-pi.ts +5 -0
- package/src/runtime/model/pi-args.ts +1 -1
- package/src/runtime/recovery/crash-recovery.ts +1 -1
- package/src/runtime/scratchpad/README.md +184 -0
- package/src/runtime/scratchpad/engine.ts +610 -0
- package/src/runtime/scratchpad/guest.ts +360 -0
- package/src/runtime/scratchpad/index.ts +22 -0
- package/src/runtime/scratchpad/protocol.ts +88 -0
- package/src/runtime/scratchpad/snapshot-lookup.ts +74 -0
- package/src/runtime/scratchpad/transform.ts +363 -0
- package/src/runtime/task-runner/child-executor.ts +48 -31
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scratchpad-lifecycle.ts — tool `execute` + EngineManager lifecycle (Phase 1 T6).
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
* - the conditional `execute` tool registration (D3 + SEC-2);
|
|
6
|
+
* - the execute handler (spec §4: dormant check → F12 shutdown branch →
|
|
7
|
+
* ping-before-execute → per-cell timeout → error-as-data);
|
|
8
|
+
* - the snapshot debounce + temp→writeArtifact flush (spec §5/§6: F4/S-1
|
|
9
|
+
* crash-safety, N2-4 chmod/unlink, F11/N2-1 fail-closed env validation);
|
|
10
|
+
* - the `session_shutdown` quit-gated flush+kill hook (spec §5: F3/F22/F13).
|
|
11
|
+
*
|
|
12
|
+
* Kept OUT of prompt-runtime.ts so that file stays focused on prompt shaping.
|
|
13
|
+
*
|
|
14
|
+
* F2: EngineManager is imported DIRECTLY from ../runtime/scratchpad/engine.ts,
|
|
15
|
+
* NOT through the barrel index.ts — the barrel re-exports transform.ts which
|
|
16
|
+
* pulls esbuild into every worker extension process at load time.
|
|
17
|
+
*
|
|
18
|
+
* Testability: the module functions take explicit deps (`engine`,
|
|
19
|
+
* `writeArtifact`, `env`, `logInternalError`) with process-defaults, so unit
|
|
20
|
+
* tests can mock the engine and the artifact writer without spawning guests.
|
|
21
|
+
*
|
|
22
|
+
* THREAT MODEL (accepted, spec §9/§14.9): execute cells run at FULL WORKER
|
|
23
|
+
* TRUST — the guest inherits the worker env (provider keys + broker token)
|
|
24
|
+
* and has network access, same boundary as the built-in bash tool. On quit we
|
|
25
|
+
* SIGKILL the guest PID only; a cell that spawned `detached:true` descendants
|
|
26
|
+
* can leave orphans holding that env. Accepted for Phase 1 (documented, not a
|
|
27
|
+
* containment guarantee); Phase 2: kill process group / orphan sweep.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
33
|
+
import { defineTool, type ExtensionAPI, type ToolDefinition } from "../extension/pi-api.ts";
|
|
34
|
+
import { EngineManager, type ExecuteResult } from "../runtime/scratchpad/engine.ts";
|
|
35
|
+
// D5/MAJOR-S1: PI_CREW_PARENT_PID + PI_CREW_GUEST build the guest's zombie-
|
|
36
|
+
// backstop env (PI_CREW_KIND_ENV is already exported above).
|
|
37
|
+
import { type ArtifactWriteOptions, writeArtifact } from "../state/stores/artifact-store.ts";
|
|
38
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
39
|
+
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
40
|
+
|
|
41
|
+
export const PI_CREW_SCRATCHPAD_ENV = "PI_CREW_SCRATCHPAD";
|
|
42
|
+
export const PI_CREW_TASK_ID_ENV = "PI_CREW_TASK_ID";
|
|
43
|
+
export const PI_CREW_ATTEMPT_ENV = "PI_CREW_ATTEMPT";
|
|
44
|
+
export const PI_CREW_ARTIFACTS_ROOT_ENV = "PI_CREW_ARTIFACTS_ROOT";
|
|
45
|
+
export const PI_CREW_SCRATCHPAD_SNAPSHOT_ENV = "PI_CREW_SCRATCHPAD_SNAPSHOT";
|
|
46
|
+
export const PI_CREW_KIND_ENV = "PI_CREW_KIND";
|
|
47
|
+
// D5/MAJOR-S1: guest zombie-backstop env keys (the guest reports the WORKER as
|
|
48
|
+
// its parent so an orphaned guest is flagged when the worker dies).
|
|
49
|
+
export const PI_CREW_PARENT_PID_ENV = "PI_CREW_PARENT_PID";
|
|
50
|
+
export const PI_CREW_GUEST_ENV = "PI_CREW_GUEST";
|
|
51
|
+
// Phase 2 crash-resume (D2): parent-set restore hint — previous attempt's
|
|
52
|
+
// snapshot artifact. Worker re-validates at READ time (D10), never trusts it.
|
|
53
|
+
export const PI_CREW_SCRATCHPAD_RESTORE_ENV = "PI_CREW_SCRATCHPAD_RESTORE";
|
|
54
|
+
// D10/MINOR-S1: swap-detection HINT (parent-pinned mtime at scan time). It is
|
|
55
|
+
// forgeable by any same-uid actor (utimesSync) — defense-in-depth only, never
|
|
56
|
+
// an integrity/authn control (NIT-CA-1).
|
|
57
|
+
export const PI_CREW_SCRATCHPAD_RESTORE_MTIME_ENV = "PI_CREW_SCRATCHPAD_RESTORE_MTIME";
|
|
58
|
+
|
|
59
|
+
/** Per-cell wall-clock bound (D9/Q2): the ONLY default anti-hang limit. */
|
|
60
|
+
export const EXECUTE_CELL_TIMEOUT_MS = 120_000;
|
|
61
|
+
/** Debounce window for the post-cell snapshot (D5/F8). */
|
|
62
|
+
export const SNAPSHOT_DEBOUNCE_MS = 1500;
|
|
63
|
+
/** SEC-10: cap a single cell so a giant payload cannot OOM the esbuild
|
|
64
|
+
* transform or the guest. */
|
|
65
|
+
export const EXECUTE_CODE_MAX_LENGTH = 262_144;
|
|
66
|
+
/** §4 step 5: stack traces are capped before they reach the model. */
|
|
67
|
+
export const MAX_ERROR_STACK_LINES = 20;
|
|
68
|
+
/** Phase 2 (D6): snapshot cap — raw byteLength measured BEFORE writeArtifact
|
|
69
|
+
* (redacted ≤ raw → conservative). Write side = flush (this module); read side
|
|
70
|
+
* = restoreState file-size cap (engine.ts) + guest per-var decode cap (T4). */
|
|
71
|
+
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
|
72
|
+
/** D10: tolerance for the parent-pinned mtime swap-detection hint. */
|
|
73
|
+
export const RESTORE_MTIME_TOLERANCE_MS = 1000;
|
|
74
|
+
|
|
75
|
+
const ExecuteParams = Type.Object({
|
|
76
|
+
code: Type.String({ minLength: 1, maxLength: EXECUTE_CODE_MAX_LENGTH }),
|
|
77
|
+
});
|
|
78
|
+
type ExecuteParams = Static<typeof ExecuteParams>;
|
|
79
|
+
|
|
80
|
+
export interface ExecuteDetails {
|
|
81
|
+
status: "ok" | "error" | "aborted";
|
|
82
|
+
durationMs: number;
|
|
83
|
+
error?: { name: string; message: string; stack: string[] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* F9: doctrine is carried by the ToolDefinition `promptGuidelines` field (the
|
|
88
|
+
* ONLY channel — pi consumes it from ACTIVE tools; see system-prompt.js +
|
|
89
|
+
* agent-session.js). It is NEVER appended manually in before_agent_start,
|
|
90
|
+
* which would produce duplicate doctrine.
|
|
91
|
+
*/
|
|
92
|
+
export const SCRATCHPAD_DOCTRINE: string[] = [
|
|
93
|
+
"State compounds: variables persist across scratchpad calls in the task's persistent namespace. Don't re-derive what a previous cell already computed.",
|
|
94
|
+
"Write small cells and run many: the cell's result is the value of its final (trailing) expression.",
|
|
95
|
+
"The runtime is Node.js — use child_process for shell commands; there is no Bun.",
|
|
96
|
+
"Writes are surgical; reads are full: read all the data you need, write the minimum.",
|
|
97
|
+
"Non-serializable variables (functions/classes) are reported in the snapshot's failed list — do not rely on them across calls.",
|
|
98
|
+
"If you see <rlm_engine_reset> or a snapshot-restore notice, re-verify variables before use.",
|
|
99
|
+
"Tool calls inside scratchpad are await expressions — await the result before the next cell.",
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// ── singleton engine + debounce timer (per worker process) ─────────────────
|
|
103
|
+
// The EngineManager INSTANCE is created eagerly at registration (cheap — no
|
|
104
|
+
// guest process), but the GUEST spawns lazily on the first execute (lazy start
|
|
105
|
+
// lives inside engine.execute → start(), F21).
|
|
106
|
+
let engineSingleton: EngineManager | undefined;
|
|
107
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
// Phase 2 crash-resume (D3): pending restore hint, captured at register time
|
|
109
|
+
// (path + parsed attempt only — NOT validated here; MAJOR-P1: validation is
|
|
110
|
+
// re-run at READ time inside the execute handler). Module-scope, not
|
|
111
|
+
// session-scope (NIT-2a): a session reload mid-run re-arms the restore —
|
|
112
|
+
// acceptable, "once per session" means once per worker process.
|
|
113
|
+
interface PendingRestore {
|
|
114
|
+
path: string;
|
|
115
|
+
attempt: number;
|
|
116
|
+
}
|
|
117
|
+
let restorePending: PendingRestore | null = null;
|
|
118
|
+
|
|
119
|
+
function getScratchpadEngine(): EngineManager {
|
|
120
|
+
engineSingleton ??= new EngineManager({
|
|
121
|
+
env: {
|
|
122
|
+
// D5 (MAJOR-S1): make the guest's recorded parent the WORKER pid, not the
|
|
123
|
+
// leader's (worker env carries PI_CREW_PARENT_PID=<leader pid> via
|
|
124
|
+
// child-pi-spawn.ts:152 — pure inheritance would leave orphaned guests
|
|
125
|
+
// classified LIVE forever, holding provider keys + broker token).
|
|
126
|
+
// options.env spreads AFTER process.env (engine.ts spawn) → overrides win.
|
|
127
|
+
// PI_CREW_GUEST distinguishes guest entries in zombie reports.
|
|
128
|
+
PI_CREW_KIND: "subagent",
|
|
129
|
+
[PI_CREW_PARENT_PID_ENV]: String(process.pid),
|
|
130
|
+
[PI_CREW_GUEST_ENV]: "1",
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
return engineSingleton;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* D5/F8: debounced snapshot scheduling. One timer alive at a time (clear
|
|
138
|
+
* before re-set). The timer is unref'd so an idle worker never blocks
|
|
139
|
+
* event-loop exit; `flushScratchpadSnapshot` swallows+logs its own errors.
|
|
140
|
+
* `delayMs` is injectable for tests only; production uses SNAPSHOT_DEBOUNCE_MS.
|
|
141
|
+
*/
|
|
142
|
+
export function scheduleScratchpadSnapshot(deps: ScratchpadSnapshotDeps, delayMs: number = SNAPSHOT_DEBOUNCE_MS): void {
|
|
143
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
144
|
+
debounceTimer = setTimeout(() => {
|
|
145
|
+
debounceTimer = undefined;
|
|
146
|
+
void flushScratchpadSnapshot(deps);
|
|
147
|
+
}, delayMs);
|
|
148
|
+
debounceTimer.unref?.();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Cancel any pending debounce snapshot (R-3/SEC-RACE-1: quit-flush must not
|
|
152
|
+
* race the debounce timer over the same temp path). */
|
|
153
|
+
export function cancelScratchpadSnapshot(): void {
|
|
154
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
155
|
+
debounceTimer = undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── env validation (F11/SEC-7 — fail-closed) ────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export interface SnapshotEnvValidation {
|
|
161
|
+
valid: boolean;
|
|
162
|
+
reason?: string;
|
|
163
|
+
taskId?: string;
|
|
164
|
+
/** Fail-open per F4/R-8: PI_CREW_ATTEMPT is always set by the parent, but
|
|
165
|
+
* missing here degrades to "0" rather than skipping the snapshot. */
|
|
166
|
+
attempt: string;
|
|
167
|
+
artifactsRoot?: string;
|
|
168
|
+
snapshotPath?: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Worker-side validation of the scratchpad snapshot env, mirroring
|
|
173
|
+
* `validateSteeringFile`'s fail-closed posture (prompt-runtime.ts).
|
|
174
|
+
*
|
|
175
|
+
* - `PI_CREW_TASK_ID` required (relativePath provenance, C3).
|
|
176
|
+
* - `PI_CREW_ATTEMPT` fail-open → "0" (R-8).
|
|
177
|
+
* - `PI_CREW_ARTIFACTS_ROOT` non-empty AND a real non-symlink directory
|
|
178
|
+
* (resolveRealContainedPath O_NOFOLLOW). We do NOT derive it from the
|
|
179
|
+
* snapshot path (N2-1 — the snapshot lives in the parent's tempDir, NOT
|
|
180
|
+
* under artifactsRoot after F4/S-1).
|
|
181
|
+
* - `PI_CREW_SCRATCHPAD_SNAPSHOT` non-empty and resolvable with no symlinked
|
|
182
|
+
* ancestors (validated against its own dirname, since its base is the
|
|
183
|
+
* parent's tempDir which the worker does not know by name).
|
|
184
|
+
*
|
|
185
|
+
* Any violation → `{ valid: false, reason }`; callers skip the snapshot write
|
|
186
|
+
* and logInternalError("scratchpad.env-validation", ...) — never derive, never
|
|
187
|
+
* write to a guessed location.
|
|
188
|
+
*/
|
|
189
|
+
export function validateSnapshotEnv(env: NodeJS.ProcessEnv = process.env): SnapshotEnvValidation {
|
|
190
|
+
const taskId = env[PI_CREW_TASK_ID_ENV];
|
|
191
|
+
const artifactsRoot = env[PI_CREW_ARTIFACTS_ROOT_ENV];
|
|
192
|
+
const snapshotPath = env[PI_CREW_SCRATCHPAD_SNAPSHOT_ENV];
|
|
193
|
+
const attempt = env[PI_CREW_ATTEMPT_ENV] ?? "0";
|
|
194
|
+
|
|
195
|
+
if (!taskId) return { valid: false, reason: `missing ${PI_CREW_TASK_ID_ENV}`, attempt };
|
|
196
|
+
if (!artifactsRoot) return { valid: false, reason: `missing ${PI_CREW_ARTIFACTS_ROOT_ENV}`, attempt };
|
|
197
|
+
if (!snapshotPath) return { valid: false, reason: `missing ${PI_CREW_SCRATCHPAD_SNAPSHOT_ENV}`, attempt };
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// artifactsRoot must exist (or be creatable) and be a real dir, not a symlink.
|
|
201
|
+
resolveRealContainedPath(artifactsRoot, ".");
|
|
202
|
+
// snapshot path: validate containment + O_NOFOLLOW ancestors against its
|
|
203
|
+
// own dirname (tempDir). Target file may not exist yet — that's fine.
|
|
204
|
+
resolveRealContainedPath(path.dirname(snapshotPath), path.basename(snapshotPath));
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
valid: false,
|
|
208
|
+
reason: `env-path-invalid:${error instanceof Error ? error.message : String(error)}`,
|
|
209
|
+
attempt,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return { valid: true, taskId, attempt, artifactsRoot, snapshotPath };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── restore validation (Phase 2 — D10, fail-closed at READ time) ───────────
|
|
216
|
+
|
|
217
|
+
export interface RestoreEnvValidation {
|
|
218
|
+
valid: boolean;
|
|
219
|
+
reason?: string;
|
|
220
|
+
path?: string;
|
|
221
|
+
attempt?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Phase 2 crash-resume (D10, MAJOR-P1): worker-side, fail-closed, 3-layer
|
|
226
|
+
* validation of the restore path, re-run at READ time (immediately before
|
|
227
|
+
* `engine.restoreState`) — the env hint is captured at register but must NOT
|
|
228
|
+
* be trusted then (TOCTOU: a same-uid team worker can swap the file between
|
|
229
|
+
* the spawn-time scan and the first execute).
|
|
230
|
+
*
|
|
231
|
+
* 1. containment: `resolveRealContainedPath` (O_NOFOLLOW ancestor walk) — the
|
|
232
|
+
* resolved path must stay inside artifactsRoot;
|
|
233
|
+
* 2. filename pattern: `^<taskId>.attempt-<digits>.snapshot.json$`;
|
|
234
|
+
* 3. regular file (lstat — symlink rejected), size ≤ SNAPSHOT_MAX_BYTES
|
|
235
|
+
* (D6 read-side cap against v8.deserialize amplification);
|
|
236
|
+
* 4. optional mtime pin vs `PI_CREW_SCRATCHPAD_RESTORE_MTIME` — swap-detection
|
|
237
|
+
* HINT (forgeable), not authn.
|
|
238
|
+
*
|
|
239
|
+
* Any violation → `{ valid: false, reason }`; the caller fail-opens (D11):
|
|
240
|
+
* no restore, empty namespace, execute continues, message never leaks paths.
|
|
241
|
+
*/
|
|
242
|
+
export function validateRestoreEnv(env: NodeJS.ProcessEnv = process.env, restorePath: string): RestoreEnvValidation {
|
|
243
|
+
const taskId = env[PI_CREW_TASK_ID_ENV];
|
|
244
|
+
const artifactsRoot = env[PI_CREW_ARTIFACTS_ROOT_ENV];
|
|
245
|
+
if (!taskId) return { valid: false, reason: `missing ${PI_CREW_TASK_ID_ENV}` };
|
|
246
|
+
if (!artifactsRoot) return { valid: false, reason: `missing ${PI_CREW_ARTIFACTS_ROOT_ENV}` };
|
|
247
|
+
try {
|
|
248
|
+
// (1) containment + O_NOFOLLOW ancestors (absolute targetPath supported).
|
|
249
|
+
const resolved = resolveRealContainedPath(artifactsRoot, restorePath);
|
|
250
|
+
// (2) exact filename pattern (taskId not interpolated into a regex — the
|
|
251
|
+
// startsWith/endsWith form keeps agentId free of injection surface).
|
|
252
|
+
const base = path.basename(resolved);
|
|
253
|
+
const prefix = `${taskId}.attempt-`;
|
|
254
|
+
if (!base.startsWith(prefix) || !base.endsWith(".snapshot.json")) {
|
|
255
|
+
return { valid: false, reason: "restore-path-pattern-mismatch" };
|
|
256
|
+
}
|
|
257
|
+
const attemptPart = base.slice(prefix.length, base.length - ".snapshot.json".length);
|
|
258
|
+
if (!/^\d+$/.test(attemptPart)) return { valid: false, reason: "restore-path-attempt-invalid" };
|
|
259
|
+
const attempt = Number.parseInt(attemptPart, 10);
|
|
260
|
+
// (3) regular file, not a symlink; size ≤ cap (D6 read-side).
|
|
261
|
+
const st = fs.lstatSync(resolved);
|
|
262
|
+
if (st.isSymbolicLink() || !st.isFile()) return { valid: false, reason: "restore-path-not-regular-file" };
|
|
263
|
+
if (st.size > SNAPSHOT_MAX_BYTES) return { valid: false, reason: "restore-path-over-cap" };
|
|
264
|
+
// (4) mtime pin (swap-detection hint — forgeable, never authn).
|
|
265
|
+
const pinned = env[PI_CREW_SCRATCHPAD_RESTORE_MTIME_ENV];
|
|
266
|
+
if (pinned) {
|
|
267
|
+
const pinnedMs = Number.parseFloat(pinned);
|
|
268
|
+
if (Number.isFinite(pinnedMs) && Math.abs(st.mtimeMs - pinnedMs) > RESTORE_MTIME_TOLERANCE_MS) {
|
|
269
|
+
return { valid: false, reason: "restore-path-mtime-mismatch" };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { valid: true, path: resolved, attempt };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return {
|
|
275
|
+
valid: false,
|
|
276
|
+
reason: `restore-path-invalid:${error instanceof Error ? error.message : String(error)}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── snapshot flush ──────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
export interface ScratchpadSnapshotDeps {
|
|
284
|
+
engine: EngineManager;
|
|
285
|
+
/** Injected for tests; production defaults to the real artifact-store writer.
|
|
286
|
+
* Return type is loose (unknown) — the flush ignores the descriptor. */
|
|
287
|
+
writeArtifact?: ScratchpadWriteArtifact;
|
|
288
|
+
env?: NodeJS.ProcessEnv;
|
|
289
|
+
logInternalError?: typeof logInternalError;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** DI-friendly signature for the artifact writer (see ScratchpadSnapshotDeps). */
|
|
293
|
+
export type ScratchpadWriteArtifact = (artifactsRoot: string, options: ArtifactWriteOptions) => unknown;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Read the RAW snapshot temp file, redact it through `writeArtifact`, then
|
|
297
|
+
* clean the temp (N2-4). Best-effort: every failure is logged via
|
|
298
|
+
* logInternalError("scratchpad.snapshot", ...) and never throws (F8/F13).
|
|
299
|
+
*
|
|
300
|
+
* Crash-safety (F4/S-1): the raw (unredacted, base64 v8.serialize) file is
|
|
301
|
+
* written by engine.snapshotState ONLY under the parent tempDir; the only
|
|
302
|
+
* writer to artifactsRoot is writeArtifact (structural + flat redaction,
|
|
303
|
+
* atomic write). A crash at any point between snapshotState and writeArtifact
|
|
304
|
+
* leaves raw bytes in TEMP, never in artifacts.
|
|
305
|
+
*/
|
|
306
|
+
export async function flushScratchpadSnapshot(deps: ScratchpadSnapshotDeps): Promise<void> {
|
|
307
|
+
const env = deps.env ?? process.env;
|
|
308
|
+
const writeArtifactFn = deps.writeArtifact ?? writeArtifact;
|
|
309
|
+
const log = deps.logInternalError ?? logInternalError;
|
|
310
|
+
|
|
311
|
+
const validation = validateSnapshotEnv(env);
|
|
312
|
+
if (!validation.valid) {
|
|
313
|
+
log("scratchpad.env-validation", new Error(validation.reason ?? "snapshot-env-invalid"), undefined, "warn");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// validation.valid ⇒ all four fields are defined (narrowed explicitly below
|
|
317
|
+
// because TS cannot narrow across separate interface properties).
|
|
318
|
+
const { attempt } = validation;
|
|
319
|
+
const taskId = validation.taskId!;
|
|
320
|
+
const artifactsRoot = validation.artifactsRoot!;
|
|
321
|
+
const snapshotPath = validation.snapshotPath!;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const snap = await deps.engine.snapshotState(snapshotPath);
|
|
325
|
+
if (!snap) {
|
|
326
|
+
// F8: snapshotState returns null when the engine is not running. An
|
|
327
|
+
// error-null (engine running but the request failed, engine.ts:534)
|
|
328
|
+
// is indistinguishable here — surface it instead of swallowing (F13).
|
|
329
|
+
if (deps.engine.isRunning) {
|
|
330
|
+
log("scratchpad.snapshot", new Error("snapshotState returned null while engine is running"));
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const tempPath = snap.path;
|
|
335
|
+
try {
|
|
336
|
+
// N2-4: engine wrote RAW with default mode (0666 & ~umask); narrow to
|
|
337
|
+
// owner-only before the content leaves the temp dir. The parent's
|
|
338
|
+
// mkdtemp dir is 0700, so this is belt-and-braces.
|
|
339
|
+
await fs.promises.chmod(tempPath, 0o600);
|
|
340
|
+
const content = await fs.promises.readFile(tempPath, "utf8");
|
|
341
|
+
// Phase 2 (D6 — write-side cap): measure RAW byteLength BEFORE writeArtifact
|
|
342
|
+
// (redaction happens inside writeArtifact; raw ≥ redacted → conservative).
|
|
343
|
+
// Trim the failed list ONLY when over cap (align spec D6 — do not trim
|
|
344
|
+
// unconditionally, NIT-4). Still over cap after trim → skip persist: keep
|
|
345
|
+
// the previous good artifact (mtime-restore picks the older one).
|
|
346
|
+
let out = content;
|
|
347
|
+
if (Buffer.byteLength(content) > SNAPSHOT_MAX_BYTES) {
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(content) as { vars?: Record<string, unknown>; failed?: { name: string; reason: string }[] };
|
|
350
|
+
const trimmed = JSON.stringify({
|
|
351
|
+
version: 1,
|
|
352
|
+
vars: parsed.vars ?? {},
|
|
353
|
+
failed: (parsed.failed ?? []).slice(0, 50),
|
|
354
|
+
});
|
|
355
|
+
if (Buffer.byteLength(trimmed) <= SNAPSHOT_MAX_BYTES) {
|
|
356
|
+
out = trimmed;
|
|
357
|
+
} else {
|
|
358
|
+
log(
|
|
359
|
+
"scratchpad.cap",
|
|
360
|
+
new Error(
|
|
361
|
+
`snapshot ${Buffer.byteLength(content)}B > cap ${SNAPSHOT_MAX_BYTES}B; skipping persist (keeps previous artifact)`,
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// not JSON (unexpected shape) — skip persist rather than write oversized.
|
|
368
|
+
log("scratchpad.cap", new Error("oversized non-JSON snapshot; skipping persist"), undefined, "warn");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
await writeArtifactFn(artifactsRoot, {
|
|
373
|
+
kind: "result",
|
|
374
|
+
relativePath: `scratchpad/${taskId}.attempt-${attempt}.snapshot.json`,
|
|
375
|
+
content: out,
|
|
376
|
+
producer: taskId,
|
|
377
|
+
});
|
|
378
|
+
} finally {
|
|
379
|
+
// R-4/N2-4: unlink the raw temp even if writeArtifact (or chmod/read)
|
|
380
|
+
// threw — best-effort, parent cleanupTempDir is the backstop.
|
|
381
|
+
await fs.promises.unlink(tempPath).catch((error) => log("scratchpad.temp-unlink", error));
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
// F8: best-effort — a snapshot failure must not kill the worker.
|
|
385
|
+
log("scratchpad.snapshot", error);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── restore notice (MINOR-3: cap the restored/failed name lists so a
|
|
390
|
+
// thousands-var snapshot cannot flood model context with a multi-MB notice) ──
|
|
391
|
+
|
|
392
|
+
function truncateNameList(names: string[], max = 50): string {
|
|
393
|
+
if (names.length <= max) return names.join(", ");
|
|
394
|
+
return `${names.slice(0, max).join(", ")}, … (+${names.length - max} more)`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── execute tool ────────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
function renderExecuteResult(result: ExecuteResult): string {
|
|
400
|
+
const lines: string[] = [];
|
|
401
|
+
lines.push(`status: ${result.status}`);
|
|
402
|
+
lines.push(`durationMs: ${result.durationMs}`);
|
|
403
|
+
if (result.stdout) lines.push(`stdout:\n${result.stdout}`);
|
|
404
|
+
if (result.stderr) lines.push(`stderr:\n${result.stderr}`);
|
|
405
|
+
if (result.result !== undefined) lines.push(`result:\n${result.result}`);
|
|
406
|
+
if (result.error) {
|
|
407
|
+
lines.push(`error: ${result.error.name}: ${result.error.message}`);
|
|
408
|
+
const stack = result.error.stack.slice(0, MAX_ERROR_STACK_LINES).join("\n");
|
|
409
|
+
if (stack) lines.push(`stack (first ${MAX_ERROR_STACK_LINES} lines):\n${stack}`);
|
|
410
|
+
}
|
|
411
|
+
return lines.join("\n\n");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export type ExecuteToolDefinition = ToolDefinition<typeof ExecuteParams, ExecuteDetails>;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Build the `execute` tool definition. `engine` is injected so tests can mock
|
|
418
|
+
* it; production passes the singleton via registerScratchpadLifecycle.
|
|
419
|
+
*
|
|
420
|
+
* Handler flow (spec §4, order is load-bearing):
|
|
421
|
+
* 1. dormant check (layer 2): env !== "1" → throw "scratchpad is dormant".
|
|
422
|
+
* 2. F12: engine.state === "shutdown" → system error (NOT "wedged").
|
|
423
|
+
* 3. ping-before-execute (S-2/N2-2): ping only when isRunning — a first cell
|
|
424
|
+
* on an idle engine must not false-positive "wedged".
|
|
425
|
+
* 4. per-cell timeout (D9/F7): ternary guard — params.signal may be
|
|
426
|
+
* undefined and AbortSignal.any([undefined, ...]) throws.
|
|
427
|
+
* 5. execute with onStream forwarded to stdout (V4-2: keeps the parent's
|
|
428
|
+
* heartbeat alive during long cells).
|
|
429
|
+
* 6. error-as-data (§4.5 + R-5): "error" AND "aborted" are returned as
|
|
430
|
+
* content, never thrown; only system failures (engine dead/wedge) throw.
|
|
431
|
+
* 7. on a successful cell, schedule the debounced snapshot.
|
|
432
|
+
*/
|
|
433
|
+
export function createExecuteTool(engine: EngineManager, deps: Partial<ScratchpadSnapshotDeps> = {}): ExecuteToolDefinition {
|
|
434
|
+
return defineTool({
|
|
435
|
+
name: "scratchpad",
|
|
436
|
+
label: "Execute JavaScript",
|
|
437
|
+
description:
|
|
438
|
+
"Chạy JavaScript trong namespace bền vững của task. State (biến gán ở cell trước) tồn tại qua các lần gọi. Kết quả cell = giá trị biểu thức cuối.",
|
|
439
|
+
parameters: ExecuteParams,
|
|
440
|
+
renderShell: "default",
|
|
441
|
+
promptSnippet: "scratchpad(code) — chạy JS trong namespace bền vững của task",
|
|
442
|
+
promptGuidelines: SCRATCHPAD_DOCTRINE,
|
|
443
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
444
|
+
const env = deps.env ?? process.env;
|
|
445
|
+
// 1. Dormant check (lớp 2 — defense in depth behind the registration gate).
|
|
446
|
+
if (env[PI_CREW_SCRATCHPAD_ENV] !== "1") {
|
|
447
|
+
throw new Error("scratchpad is dormant");
|
|
448
|
+
}
|
|
449
|
+
// 2. F12: terminal shutdown state — a distinct system error, not "wedged".
|
|
450
|
+
if (engine.state === "shutdown") {
|
|
451
|
+
throw new Error("scratchpad engine đã chết (shutdown)");
|
|
452
|
+
}
|
|
453
|
+
// 2.5. Phase 2 crash-resume (D3/D10/D11): restore ONCE on the first execute.
|
|
454
|
+
// MAJOR-P1: re-validate at READ time (TOCTOU — the file may have been
|
|
455
|
+
// swapped since the spawn-time scan). D11 fail-open: any failure logs
|
|
456
|
+
// and continues with an EMPTY namespace — restore is best-effort, never
|
|
457
|
+
// a precondition. Message never embeds paths (NIT-2b).
|
|
458
|
+
let restoreNotice: string | null = null;
|
|
459
|
+
if (restorePending) {
|
|
460
|
+
const pending = restorePending;
|
|
461
|
+
restorePending = null; // NIT-1: null IMMEDIATELY (before any await) — two
|
|
462
|
+
// overlapping execute dispatches would otherwise both restore (D3 violation).
|
|
463
|
+
const v = validateRestoreEnv(env, pending.path);
|
|
464
|
+
try {
|
|
465
|
+
if (!v.valid) {
|
|
466
|
+
// NIT-2: observable (sanitized, no path) rejection reason.
|
|
467
|
+
logInternalError("scratchpad.restore", new Error(`rejected:${v.reason ?? "unknown"}`));
|
|
468
|
+
}
|
|
469
|
+
const r = v.valid ? await engine.restoreState(v.path!) : null; // auto-start inside restoreState
|
|
470
|
+
restoreNotice = r
|
|
471
|
+
? `[scratchpad] restored ${r.restored.length} vars from attempt-${pending.attempt}; restored: [${truncateNameList(r.restored)}]; failed: [${truncateNameList(r.failed.map((f) => f.name))}]`
|
|
472
|
+
: "[scratchpad] snapshot restore: no state to restore (fail-open)";
|
|
473
|
+
} catch (error) {
|
|
474
|
+
logInternalError("scratchpad.restore", error);
|
|
475
|
+
restoreNotice = "[scratchpad] snapshot restore failed; continuing with empty namespace";
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// 3. Ping-before-execute (S-2/N2-2): skip on idle so the first cell of a
|
|
479
|
+
// session never false-positives (listNamespaceNames → null when idle).
|
|
480
|
+
if (engine.isRunning) {
|
|
481
|
+
const ok = await engine.listNamespaceNames();
|
|
482
|
+
if (ok === null) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
"scratchpad engine wedged — previous cell blocked the event loop; restart task or avoid sync infinite loops",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// 4. Per-cell timeout (D9/F7 ternary guard).
|
|
489
|
+
const cellSignal = signal
|
|
490
|
+
? AbortSignal.any([signal, AbortSignal.timeout(EXECUTE_CELL_TIMEOUT_MS)])
|
|
491
|
+
: AbortSignal.timeout(EXECUTE_CELL_TIMEOUT_MS);
|
|
492
|
+
// 5. Execute (lazy-start lives inside engine.execute).
|
|
493
|
+
let result: ExecuteResult;
|
|
494
|
+
try {
|
|
495
|
+
result = await engine.execute(params.code, {
|
|
496
|
+
signal: cellSignal,
|
|
497
|
+
onStream: (chunk) => {
|
|
498
|
+
// V4-2: forward guest output to worker stdout so the parent's
|
|
499
|
+
// heartbeat (persistHeartbeat on stdout/JSON events) fires
|
|
500
|
+
// during long-running cells.
|
|
501
|
+
process.stdout.write(chunk);
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// System error — the model cannot fix a dead/wedged engine.
|
|
506
|
+
throw new Error(`scratchpad engine failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
507
|
+
}
|
|
508
|
+
// 6. Error-as-data (R-5): "error" and "aborted" both come back as content.
|
|
509
|
+
const details: ExecuteDetails = {
|
|
510
|
+
status: result.status,
|
|
511
|
+
durationMs: result.durationMs,
|
|
512
|
+
...(result.error
|
|
513
|
+
? {
|
|
514
|
+
error: {
|
|
515
|
+
name: result.error.name,
|
|
516
|
+
message: result.error.message,
|
|
517
|
+
stack: result.error.stack.slice(0, MAX_ERROR_STACK_LINES),
|
|
518
|
+
},
|
|
519
|
+
}
|
|
520
|
+
: {}),
|
|
521
|
+
};
|
|
522
|
+
// 7. Debounced snapshot after a healthy cell (D5/F8).
|
|
523
|
+
if (result.status === "ok") {
|
|
524
|
+
scheduleScratchpadSnapshot({ ...deps, engine });
|
|
525
|
+
}
|
|
526
|
+
const text = restoreNotice ? `${restoreNotice}\n\n${renderExecuteResult(result)}` : renderExecuteResult(result);
|
|
527
|
+
return {
|
|
528
|
+
content: [{ type: "text", text }],
|
|
529
|
+
details,
|
|
530
|
+
};
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── shutdown flush (F3/F22/F13) ─────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Quit-path flush: snapshot → writeArtifact → kill, done manually (NOT
|
|
539
|
+
* engine.dispose) so writeArtifact stays the single artifacts writer (D5).
|
|
540
|
+
* Guarded by `engine.isRunning` (F22): an idle engine has no guest to flush
|
|
541
|
+
* and snapshotState would return null (avoiding a chmod on a missing file).
|
|
542
|
+
*/
|
|
543
|
+
export async function performShutdownFlush(engine: EngineManager, deps: Partial<ScratchpadSnapshotDeps> = {}): Promise<void> {
|
|
544
|
+
if (!engine.isRunning) return;
|
|
545
|
+
cancelScratchpadSnapshot();
|
|
546
|
+
await flushScratchpadSnapshot({ ...deps, engine });
|
|
547
|
+
await engine.kill();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── extension hook ──────────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
export interface ScratchpadLifecycleOptions extends Omit<ScratchpadSnapshotDeps, "engine"> {
|
|
553
|
+
/** Override the lazy singleton (tests). */
|
|
554
|
+
engine?: EngineManager;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Wire the scratchpad lifecycle into an extension API:
|
|
559
|
+
* - D3 + SEC-2: register the `execute` tool ONLY when
|
|
560
|
+
* PI_CREW_SCRATCHPAD === "1" && PI_CREW_KIND === "subagent" (workers always
|
|
561
|
+
* carry PI_CREW_KIND=subagent; a main session never does, so a leaked env
|
|
562
|
+
* cannot activate the tool in the user session);
|
|
563
|
+
* - session_shutdown (F3): flush+kill ONLY on reason === "quit";
|
|
564
|
+
* reload/new/resume/fork are no-ops (state:"shutdown" is terminal — an
|
|
565
|
+
* early kill would break every future execute).
|
|
566
|
+
*/
|
|
567
|
+
export function registerScratchpadLifecycle(pi: ExtensionAPI, options: ScratchpadLifecycleOptions = {}): void {
|
|
568
|
+
const engine = options.engine ?? getScratchpadEngine();
|
|
569
|
+
const deps: ScratchpadSnapshotDeps = { ...options, engine };
|
|
570
|
+
|
|
571
|
+
if (shouldRegisterScratchpadTool(options.env ?? process.env)) {
|
|
572
|
+
pi.registerTool(createExecuteTool(engine, deps));
|
|
573
|
+
// Phase 2 crash-resume (D3): capture the restore hint — path + parsed
|
|
574
|
+
// attempt ONLY. NOT validated here (MAJOR-P1: re-validated at READ time).
|
|
575
|
+
// Reset first: each register is a fresh worker session (a session reload
|
|
576
|
+
// re-arms restore via the env — NIT-2a, module-scope is intentional).
|
|
577
|
+
const env = options.env ?? process.env;
|
|
578
|
+
const restorePath = env[PI_CREW_SCRATCHPAD_RESTORE_ENV];
|
|
579
|
+
if (restorePath) {
|
|
580
|
+
const m = restorePath.match(/\.attempt-(\d+)\.snapshot\.json$/);
|
|
581
|
+
restorePending = { path: restorePath, attempt: m ? Number.parseInt(m[1], 10) : 0 };
|
|
582
|
+
} else {
|
|
583
|
+
restorePending = null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
pi.on("session_shutdown", (event) => {
|
|
588
|
+
if (event.reason !== "quit") return;
|
|
589
|
+
if (!engine.isRunning) return;
|
|
590
|
+
cancelScratchpadSnapshot(); // R-3/SEC-RACE-1: no debounce/quit race over the temp path
|
|
591
|
+
return performShutdownFlush(engine, deps).catch((error) => {
|
|
592
|
+
// F13: teardown must not throw, but must not swallow silently.
|
|
593
|
+
logInternalError("scratchpad.shutdown-flush", error);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* D3 + SEC-2 registration gate (pure, testable): env "1" AND subagent kind.
|
|
600
|
+
* The handler's own dormant check (layer 2) is env-only — the worker env is
|
|
601
|
+
* the single source of truth per F15.
|
|
602
|
+
*/
|
|
603
|
+
export function shouldRegisterScratchpadTool(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
604
|
+
return env[PI_CREW_SCRATCHPAD_ENV] === "1" && env[PI_CREW_KIND_ENV] === "subagent";
|
|
605
|
+
}
|
|
@@ -19,11 +19,14 @@
|
|
|
19
19
|
import type { SpawnOptions } from "node:child_process";
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
+
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
22
23
|
import { WINDOWS_ESSENTIAL_ENV_VARS } from "../../utils/env-allowlist.ts";
|
|
23
24
|
import { buildScopedAllowList, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
24
25
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
27
|
+
import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
|
|
26
28
|
import { getPiSpawnCommand } from "../pi-spawn.ts";
|
|
29
|
+
import { findLatestScratchpadSnapshot } from "../scratchpad/snapshot-lookup.ts";
|
|
27
30
|
import type { ChildPiRunInput, ChildPiRunResult } from "./child-pi.ts";
|
|
28
31
|
|
|
29
32
|
// ── Env allowlist (base set always passed to children) ──────────────────
|
|
@@ -271,6 +274,44 @@ export function prepareSpawnContext(
|
|
|
271
274
|
if (input.runId) built.env.PI_CREW_BROKER_RUN_ID = input.runId;
|
|
272
275
|
if (input.agentId) built.env.PI_CREW_BROKER_TASK_ID = input.agentId;
|
|
273
276
|
}
|
|
277
|
+
// Phase 1 scratchpad: opt in the persistent Bun-free JS evaluator (execute
|
|
278
|
+
// tool) for this worker. Gated by role/agent (S-6 read-only roles never;
|
|
279
|
+
// F6 agent.scratchpad===false kills). All keys are PI_CREW_* control vars →
|
|
280
|
+
// pass assertOnlyControlEnvKeys. Only set when there is a task id to bind
|
|
281
|
+
// the snapshot to (always present on the child-executor path).
|
|
282
|
+
if (input.agentId && isScratchpadEnabledForRole(input.role ?? input.agent.name, input.agent)) {
|
|
283
|
+
built.env.PI_CREW_SCRATCHPAD = "1"; // dormant gate (worker conditional registerTool)
|
|
284
|
+
built.env.PI_CREW_TASK_ID = input.agentId; // snapshot relativePath provenance
|
|
285
|
+
built.env.PI_CREW_ATTEMPT = String(input.attempt ?? 0); // C3 per-attempt suffix
|
|
286
|
+
if (input.artifactsRoot) {
|
|
287
|
+
// N2-1: worker reads this DIRECTLY as writeArtifact's artifactsRoot — do
|
|
288
|
+
// NOT derive from the snapshot path (snapshot is in tempDir, not under
|
|
289
|
+
// artifactsRoot after F4/S-1).
|
|
290
|
+
built.env.PI_CREW_ARTIFACTS_ROOT = input.artifactsRoot;
|
|
291
|
+
}
|
|
292
|
+
// F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
|
|
293
|
+
// point it at a temp dir; the worker reads it then writeArtifact()
|
|
294
|
+
// (redact+atomic) is the ONLY writer into artifactsRoot.
|
|
295
|
+
// R3-1: built.tempDir is only created by buildPiWorkerArgs when the agent
|
|
296
|
+
// has a systemPrompt OR the task exceeds TASK_ARG_LIMIT — guard against
|
|
297
|
+
// undefined (resolveRealContainedPath(undefined) would TypeError and crash
|
|
298
|
+
// spawn). createSafeTempDir auto-tracks the dir for cleanupAllTrackedTempDirs.
|
|
299
|
+
const scratchTempDir = built.tempDir ?? createSafeTempDir(getPiTempBase(), "pi-crew-scratchpad-");
|
|
300
|
+
built.env.PI_CREW_SCRATCHPAD_SNAPSHOT = resolveRealContainedPath(scratchTempDir, `${input.agentId}.snapshot.json`);
|
|
301
|
+
// Phase 2 crash-resume (D1/D1b/D2): locate the latest snapshot artifact of a
|
|
302
|
+
// PREVIOUS attempt (retry round / crash-recovery re-queue / manual re-run)
|
|
303
|
+
// and hand its path to the worker via PI_CREW_SCRATCHPAD_RESTORE. The worker
|
|
304
|
+
// re-validates at READ time (D10) — this env is a hint, not a trust anchor.
|
|
305
|
+
// Latest-mtime wins (model-fallback i resets each retry round); RESTORE_MTIME
|
|
306
|
+
// lets the worker detect a swap between spawn and first execute (MINOR-S1).
|
|
307
|
+
const restoreHit = input.artifactsRoot ? findLatestScratchpadSnapshot(input.artifactsRoot, input.agentId) : null;
|
|
308
|
+
if (restoreHit) {
|
|
309
|
+
built.env.PI_CREW_SCRATCHPAD_RESTORE = restoreHit.path;
|
|
310
|
+
// NIT-CA-1: mtime pin is a SWAP-DETECTION HINT (defense-in-depth), never
|
|
311
|
+
// an integrity/authn control — any same-uid actor can utimesSync it.
|
|
312
|
+
built.env.PI_CREW_SCRATCHPAD_RESTORE_MTIME = String(restoreHit.mtimeMs);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
274
315
|
// B5: if the parent already aborted before we spawn, do not start the child
|
|
275
316
|
// at all. Spawning a doomed process wastes resources, and the abort listener
|
|
276
317
|
// registered below will not re-fire for an already-aborted signal (so the
|
|
@@ -147,6 +147,11 @@ export interface ChildPiRunInput {
|
|
|
147
147
|
thinkingOverride?: string;
|
|
148
148
|
/** Root directory for artifacts (used to validate transcriptPath). */
|
|
149
149
|
artifactsRoot?: string;
|
|
150
|
+
/** Phase 1 scratchpad: model-fallback attempt index (0-based) for per-attempt
|
|
151
|
+
* snapshot relativePath `scratchpad/<taskId>.attempt-<attempt>.snapshot.json`
|
|
152
|
+
* (C3). Optional — worker defaults to attempt 0 when unset (e.g. custom
|
|
153
|
+
* agent spawn outside child-executor). */
|
|
154
|
+
attempt?: number;
|
|
150
155
|
/**
|
|
151
156
|
* Optional broker spawn context (Phase 0 inter-pi broker). When present,
|
|
152
157
|
* `prepareSpawnContext` injects `PI_CREW_BROKER_SOCKET` and
|