pi-crew 0.6.1 → 0.6.4
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 +194 -0
- package/README.md +81 -33
- package/docs/issue-29-analysis.md +189 -0
- package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -2
- package/src/config/config.ts +106 -15
- package/src/errors.ts +107 -0
- package/src/extension/async-notifier.ts +6 -2
- package/src/extension/crew-cleanup.ts +8 -5
- package/src/extension/management.ts +464 -109
- package/src/extension/register.ts +213 -35
- package/src/extension/registration/brief-tool-overrides.ts +400 -0
- package/src/extension/registration/commands.ts +27 -2
- package/src/extension/registration/subagent-helpers.ts +2 -2
- package/src/extension/registration/subagent-tools.ts +9 -4
- package/src/extension/registration/team-tool.ts +17 -10
- package/src/extension/registration/viewers.ts +2 -2
- package/src/extension/team-tool/api.ts +3 -3
- package/src/extension/team-tool/cancel.ts +3 -3
- package/src/extension/team-tool/explain.ts +1 -1
- package/src/extension/team-tool/handle-schedule.ts +40 -0
- package/src/extension/team-tool/inspect.ts +3 -3
- package/src/extension/team-tool/lifecycle-actions.ts +4 -4
- package/src/extension/team-tool/respond.ts +2 -2
- package/src/extension/team-tool/run.ts +64 -14
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool-types.ts +2 -0
- package/src/extension/team-tool.ts +173 -46
- package/src/hooks/registry.ts +77 -13
- package/src/hooks/types.ts +9 -0
- package/src/plugins/plugin-define.ts +6 -0
- package/src/plugins/plugin-registry.ts +32 -0
- package/src/plugins/plugins/index.ts +3 -0
- package/src/plugins/plugins/nextjs.ts +19 -0
- package/src/plugins/plugins/vite.ts +14 -0
- package/src/plugins/plugins/vitest.ts +14 -0
- package/src/runtime/async-runner.ts +35 -7
- package/src/runtime/background-runner.ts +529 -144
- package/src/runtime/chain-parser.ts +5 -1
- package/src/runtime/chain-runner.ts +9 -3
- package/src/runtime/checkpoint.ts +262 -180
- package/src/runtime/child-pi.ts +164 -37
- package/src/runtime/crash-recovery.ts +25 -14
- package/src/runtime/crew-agent-records.ts +2 -0
- package/src/runtime/diagnostic-export.ts +1 -1
- package/src/runtime/dynamic-script-runner.ts +10 -7
- package/src/runtime/foreground-watchdog.ts +1 -1
- package/src/runtime/heartbeat-watcher.ts +19 -2
- package/src/runtime/intercom-bridge.ts +7 -0
- package/src/runtime/iteration-hooks.ts +1 -1
- package/src/runtime/manifest-cache.ts +4 -0
- package/src/runtime/orphan-worker-registry.ts +444 -0
- package/src/runtime/parent-guard.ts +70 -16
- package/src/runtime/pi-args.ts +437 -14
- package/src/runtime/pi-spawn.ts +1 -0
- package/src/runtime/post-checks.ts +1 -1
- package/src/runtime/retry-runner.ts +9 -3
- package/src/runtime/run-tracker.ts +38 -10
- package/src/runtime/sandbox.ts +17 -2
- package/src/runtime/scheduler.ts +25 -2
- package/src/runtime/skill-effectiveness.ts +105 -62
- package/src/runtime/skill-instructions.ts +110 -32
- package/src/runtime/stale-reconciler.ts +310 -69
- package/src/runtime/subagent-manager.ts +251 -57
- package/src/runtime/task-health.ts +76 -0
- package/src/runtime/task-id.ts +20 -0
- package/src/runtime/task-runner/live-executor.ts +1 -1
- package/src/runtime/task-runner/progress.ts +1 -1
- package/src/runtime/task-runner/state-helpers.ts +110 -6
- package/src/runtime/task-runner.ts +92 -70
- package/src/runtime/team-runner.ts +186 -20
- package/src/schema/team-tool-schema.ts +27 -9
- package/src/skills/discover-skills.ts +9 -3
- package/src/state/active-run-registry.ts +170 -38
- package/src/state/artifact-store.ts +25 -13
- package/src/state/atomic-write-v2.ts +86 -0
- package/src/state/atomic-write.ts +346 -55
- package/src/state/blob-store.ts +178 -10
- package/src/state/crew-init.ts +161 -28
- package/src/state/decision-ledger.ts +172 -111
- package/src/state/event-log-rotation.ts +82 -52
- package/src/state/event-log.ts +254 -70
- package/src/state/health-store.ts +71 -0
- package/src/state/locks.ts +102 -20
- package/src/state/mailbox.ts +45 -7
- package/src/state/observation-store.ts +4 -1
- package/src/state/run-graph.ts +141 -130
- package/src/state/run-metrics.ts +24 -8
- package/src/state/session-state-map.ts +51 -0
- package/src/state/state-store.ts +330 -43
- package/src/ui/live-run-sidebar.ts +1 -1
- package/src/ui/loaders.ts +4 -0
- package/src/ui/overlays/agent-picker-overlay.ts +1 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
- package/src/ui/powerbar-publisher.ts +1 -1
- package/src/ui/run-action-dispatcher.ts +2 -2
- package/src/ui/run-snapshot-cache.ts +1 -1
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +627 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/env-filter.ts +66 -0
- package/src/utils/file-coalescer.ts +9 -1
- package/src/utils/guards.ts +110 -0
- package/src/utils/paths.ts +80 -5
- package/src/utils/redaction.ts +6 -1
- package/src/utils/safe-paths.ts +281 -10
- package/src/worktree/cleanup.ts +112 -24
- package/src/worktree/worktree-manager.ts +128 -15
- package/test-bugs-all.mjs +10 -6
- package/test-integration-check.ts +4 -0
package/src/runtime/child-pi.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guar
|
|
|
11
11
|
import { redactJsonLine } from "../utils/redaction.ts";
|
|
12
12
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
13
13
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
14
|
+
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
14
15
|
|
|
15
16
|
const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
|
|
16
17
|
const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
|
|
@@ -159,6 +160,8 @@ export interface ChildPiRunInput {
|
|
|
159
160
|
agentId?: string;
|
|
160
161
|
/** Role for tool restrictions (from role-tools.ts) */
|
|
161
162
|
role?: string;
|
|
163
|
+
/** Root directory for artifacts (used to validate transcriptPath). */
|
|
164
|
+
artifactsRoot?: string;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
167
|
export interface ChildPiRunResult {
|
|
@@ -174,10 +177,34 @@ export interface ChildPiRunResult {
|
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
|
|
180
|
+
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
181
|
+
// If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
|
|
182
|
+
// could cause the child process to operate in an attacker-controlled directory,
|
|
183
|
+
// enabling path traversal attacks, unintended file access, or exposure of sensitive paths.
|
|
184
|
+
// Use realpathSync to resolve any symlinks and verify the path exists and is a directory.
|
|
185
|
+
let validatedCwd: string;
|
|
186
|
+
try {
|
|
187
|
+
validatedCwd = fs.realpathSync(cwd);
|
|
188
|
+
const stats = fs.statSync(validatedCwd);
|
|
189
|
+
if (!stats.isDirectory()) {
|
|
190
|
+
throw new Error(`cwd is not a directory: ${cwd}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// If cwd doesn't exist (ENOENT) and isn't a security concern, fall back
|
|
194
|
+
// to the lexical path. The child process will create the directory if
|
|
195
|
+
// needed. Throwing would break tests/callers that pass not-yet-existing
|
|
196
|
+
// paths and isn't a security issue for the env-filtering behavior this
|
|
197
|
+
// function is primarily about.
|
|
198
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && error instanceof Error && error.message.includes("ENOENT")) {
|
|
199
|
+
validatedCwd = path.resolve(cwd);
|
|
200
|
+
} else {
|
|
201
|
+
throw new Error(`Invalid cwd: ${cwd} — ${error instanceof Error ? error.message : String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
// Filter out env vars whose keys match secret patterns to avoid leaking credentials to child processes.
|
|
178
206
|
// IMPORTANT: preserve model provider API keys — they are needed by the child Pi to call the LLM.
|
|
179
207
|
// Also preserve essential non-secret vars (PATH, HOME, USER, etc.) so the child process can function.
|
|
180
|
-
// Bug #10 fix: allow-list preserves model provider keys.
|
|
181
208
|
// Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
|
|
182
209
|
const filteredEnv = sanitizeEnvSecrets(env, {
|
|
183
210
|
allowList: [
|
|
@@ -190,26 +217,14 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
|
|
|
190
217
|
* - sanitizeEnvSecrets strips all env vars NOT on this list.
|
|
191
218
|
* - Do NOT add wildcards ("*_API_KEY") — only explicit, intended provider keys.
|
|
192
219
|
* - Consider per-task key scoping if the architecture allows it in the future.
|
|
220
|
+
*
|
|
221
|
+
* MAINTENANCE REQUIREMENT: When new secret env vars are added to the Pi ecosystem,
|
|
222
|
+
* they MUST be explicitly added to this allowlist to be passed to child processes.
|
|
223
|
+
* A CI check should fail if a secret-like env var (matching patterns like *_API_KEY,
|
|
224
|
+
* *_TOKEN, *_SECRET) is detected in the codebase but not present in this list.
|
|
193
225
|
*/
|
|
194
|
-
// Model provider API keys
|
|
195
|
-
|
|
196
|
-
"MINIMAX_GROUP_ID",
|
|
197
|
-
"OPENAI_API_KEY",
|
|
198
|
-
"OPENAI_ORG_ID",
|
|
199
|
-
"ANTHROPIC_API_KEY",
|
|
200
|
-
"GOOGLE_API_KEY",
|
|
201
|
-
"GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
|
|
202
|
-
"AZURE_OPENAI_API_KEY",
|
|
203
|
-
"AZURE_OPENAI_ENDPOINT",
|
|
204
|
-
"AWS_ACCESS_KEY_ID",
|
|
205
|
-
"AWS_SECRET_ACCESS_KEY",
|
|
206
|
-
"AWS_REGION",
|
|
207
|
-
"ZEU_API_KEY",
|
|
208
|
-
"ZERODEV_API_KEY",
|
|
209
|
-
// SECURITY FIX: Removed dangerous wildcards "*_API_KEY", "*_TOKEN", "*_SECRET"
|
|
210
|
-
// These patterns would leak ALL secrets matching the pattern to child processes.
|
|
211
|
-
// Only add specific, intended provider keys above.
|
|
212
|
-
// Essential non-secret vars for child process to function
|
|
226
|
+
// NOTE: Model provider API keys are NOT needed here — child Pi uses the same
|
|
227
|
+
// config file as parent Pi. Passing keys via env is a security risk.
|
|
213
228
|
"PATH",
|
|
214
229
|
"HOME",
|
|
215
230
|
"USER",
|
|
@@ -233,34 +248,105 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
|
|
|
233
248
|
"NVM_BIN",
|
|
234
249
|
"NVM_DIR",
|
|
235
250
|
"NVM_INC",
|
|
236
|
-
|
|
251
|
+
// NODE_PATH is intentionally omitted from the allowlist.
|
|
252
|
+
// NODE_PATH can reveal user environment information (e.g., NVM paths under $HOME)
|
|
253
|
+
// and the validation at lines 286-298 only filters to standard system prefixes.
|
|
254
|
+
// Removing it entirely is cleaner than best-effort filtering.
|
|
237
255
|
"NODE_DISABLE_COLORS",
|
|
238
256
|
"NODE_EXTRA_CA_CERTS",
|
|
239
257
|
"NPM_CONFIG_REGISTRY",
|
|
240
258
|
"NPM_CONFIG_USERCONFIG",
|
|
241
259
|
"NPM_CONFIG_GLOBALCONFIG",
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
260
|
+
// FIX: Replace PI_CREW_*/PI_TEAMS_* wildcards with explicit list of
|
|
261
|
+
// safe vars. Wildcards are fragile — any new secret var would leak.
|
|
262
|
+
// Only non-secret execution-control vars that children legitimately need.
|
|
263
|
+
"PI_CREW_DEPTH",
|
|
264
|
+
"PI_CREW_MAX_DEPTH",
|
|
265
|
+
"PI_CREW_INHERIT_PROJECT_CONTEXT",
|
|
266
|
+
"PI_CREW_INHERIT_SKILLS",
|
|
267
|
+
// PI_CREW_PARENT_PID is needed by child-pi's parent-guard (uses
|
|
268
|
+
// process.kill(pid, 0) liveness check). The PID is not a secret.
|
|
269
|
+
"PI_CREW_PARENT_PID",
|
|
270
|
+
"PI_TEAMS_DEPTH",
|
|
271
|
+
"PI_TEAMS_MAX_DEPTH",
|
|
272
|
+
"PI_TEAMS_INHERIT_PROJECT_CONTEXT",
|
|
273
|
+
"PI_TEAMS_INHERIT_SKILLS",
|
|
274
|
+
"PI_TEAMS_PI_BIN",
|
|
275
|
+
"PI_TEAMS_MOCK_CHILD_PI",
|
|
276
|
+
"PI_CREW_ALLOW_MOCK",
|
|
245
277
|
],
|
|
246
278
|
});
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
279
|
+
// FIX: Removed delete workarounds — with explicit allowlist, these vars
|
|
280
|
+
// are no longer auto-leaked. The wildcard approach was fragile.
|
|
281
|
+
|
|
282
|
+
// SECURITY FIX (Issue #1): Validate NODE_PATH to ensure it only contains standard
|
|
283
|
+
// system locations or legitimate user paths (NVM). NODE_PATH can reveal user
|
|
284
|
+
// environment information and could theoretically be exploited if it contains
|
|
285
|
+
// untrusted entries. Only allow paths under standard system directories
|
|
286
|
+
// (/opt, /lib, /usr) or NVM paths under /home/<user>/.nvm/... which are legitimate
|
|
287
|
+
// for Node.js module loading in user environments.
|
|
288
|
+
if (filteredEnv.NODE_PATH) {
|
|
289
|
+
const validPrefixes = ["/opt/", "/lib/", "/usr/local/", "/usr/", "/home/"];
|
|
290
|
+
const validPaths = filteredEnv.NODE_PATH.split(":").filter((p) => {
|
|
291
|
+
return validPrefixes.some((prefix) => p.startsWith(prefix));
|
|
292
|
+
});
|
|
293
|
+
if (validPaths.length > 0) {
|
|
294
|
+
filteredEnv.NODE_PATH = validPaths.join(":");
|
|
295
|
+
} else {
|
|
296
|
+
// No standard paths found — remove NODE_PATH entirely to avoid
|
|
297
|
+
// passing user-specific paths that could reveal environment info.
|
|
298
|
+
delete filteredEnv.NODE_PATH;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
250
302
|
return {
|
|
251
|
-
cwd,
|
|
303
|
+
cwd: validatedCwd,
|
|
252
304
|
env: { ...filteredEnv, PI_CREW_PARENT_PID: String(process.pid) },
|
|
253
305
|
stdio: ["ignore", "pipe", "pipe"], // stdin=ignore: child doesn't wait for input; task comes via CLI args
|
|
254
306
|
detached: process.platform !== "win32",
|
|
255
307
|
setsid: true,
|
|
308
|
+
// NOTE: setsid creates a new session; the child process becomes the session leader
|
|
309
|
+
// and its parent becomes that session leader (still the team-runner in the same
|
|
310
|
+
// process group). PI_CREW_PARENT_PID is set before spawn using process.pid (team-runner).
|
|
311
|
+
// The parent-guard in the child checks direct parent liveness via process.kill(pid, 0) —
|
|
312
|
+
// it does NOT follow the lineage beyond the direct parent. If the team-runner's parent
|
|
313
|
+
// (the original pi session) dies, the team-runner becomes an orphan but the child still
|
|
314
|
+
// sees its direct parent (team-runner) as alive. This is correct for the parent-guard model.
|
|
256
315
|
windowsHide: true,
|
|
257
316
|
} as SpawnOptions;
|
|
258
317
|
}
|
|
259
318
|
|
|
260
319
|
function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
261
320
|
if (!input.transcriptPath) return;
|
|
262
|
-
|
|
263
|
-
|
|
321
|
+
// SECURITY FIX (Issue #1): Validate transcriptPath against artifactsRoot to prevent
|
|
322
|
+
// arbitrary file writes and symlink traversal attacks. An attacker who can influence
|
|
323
|
+
// the task graph could set transcriptPath to /etc/passwd or similar, and mkdirSync
|
|
324
|
+
// with recursive:true would create parent directories. Additionally, appendFileSync
|
|
325
|
+
// follows symlinks, potentially writing to sensitive files.
|
|
326
|
+
let safePath: string;
|
|
327
|
+
try {
|
|
328
|
+
const artifactsRoot = input.artifactsRoot ?? input.cwd;
|
|
329
|
+
safePath = resolveRealContainedPath(artifactsRoot, input.transcriptPath);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
logInternalError("child-pi.transcript-path-rejected", error as Error, `transcriptPath=${input.transcriptPath}`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
// Use O_NOFOLLOW | O_CREAT | O_APPEND to safely open the transcript file.
|
|
335
|
+
// O_NOFOLLOW prevents symlink attacks (refuses to follow symlinks).
|
|
336
|
+
// O_CREAT creates the file if it doesn't exist.
|
|
337
|
+
// O_APPEND atomically positions at end for each write (no seek race).
|
|
338
|
+
// O_EXCL was previously used but prevented appending to existing files,
|
|
339
|
+
// causing EBADF on subsequent writes.
|
|
340
|
+
// NOTE: Parent directory must already exist (caller's responsibility).
|
|
341
|
+
// We skip mkdirSync here for security — adding it would create parent
|
|
342
|
+
// directories during validation, contradicting the original design where
|
|
343
|
+
// resolveRealContainedPath validates a pre-existing path.
|
|
344
|
+
const fd = fs.openSync(safePath, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
|
|
345
|
+
try {
|
|
346
|
+
fs.writeSync(fd, `${redactJsonLine(line)}\n`, undefined, "utf-8");
|
|
347
|
+
} finally {
|
|
348
|
+
fs.closeSync(fd);
|
|
349
|
+
}
|
|
264
350
|
}
|
|
265
351
|
|
|
266
352
|
function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS): string {
|
|
@@ -415,9 +501,16 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
415
501
|
if (depth.blocked) return { exitCode: 1, stdout: "", stderr: `pi-crew depth guard blocked child worker: depth ${depth.depth} >= max ${depth.maxDepth}` };
|
|
416
502
|
const mock = process.env.PI_TEAMS_MOCK_CHILD_PI;
|
|
417
503
|
if (mock) {
|
|
418
|
-
// SECURITY:
|
|
419
|
-
//
|
|
420
|
-
//
|
|
504
|
+
// SECURITY (Issue #2): Mock mode security model is intentionally asymmetric.
|
|
505
|
+
// PI_TEAMS_MOCK_CHILD_PI is in the allowlist (passed to children) but
|
|
506
|
+
// PI_CREW_ALLOW_MOCK is NOT in the allowlist — it is only checked in the
|
|
507
|
+
// parent process scope. This means:
|
|
508
|
+
// (1) If an attacker sets PI_CREW_ALLOW_MOCK in the parent's environment,
|
|
509
|
+
// it will NOT be passed to child processes (safe).
|
|
510
|
+
// (2) Mock mode activation in the child always fails the PI_CREW_ALLOW_MOCK
|
|
511
|
+
// check, so mock mode can only be triggered from the parent process.
|
|
512
|
+
// This asymmetry is intentional: PI_CREW_ALLOW_MOCK must be set in the Pi root
|
|
513
|
+
// process (the entry point that spawns children), not inherited from a parent.
|
|
421
514
|
// Setup hooks cannot inject PI_CREW_ALLOW_MOCK into the parent's env.
|
|
422
515
|
const allowMock = process.env.PI_CREW_ALLOW_MOCK === "1" || process.env.PI_CREW_ALLOW_MOCK === "true";
|
|
423
516
|
if (!allowMock) {
|
|
@@ -445,6 +538,20 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
445
538
|
const spawnSpec = getPiSpawnCommand(built.args);
|
|
446
539
|
try {
|
|
447
540
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
541
|
+
// SECURITY (Issue #3): built.env contains only PI_CREW_* execution-control vars (NOT secrets).
|
|
542
|
+
// It is safe to spread built.env after process.env because sanitizeEnvSecrets will filter
|
|
543
|
+
// any secret values before the env reaches spawn(). However, if built.env ever gains
|
|
544
|
+
// secret content without corresponding allowlist filtering, secrets would leak to children.
|
|
545
|
+
// This comment serves as a warning: built.env must never contain secret values.
|
|
546
|
+
//
|
|
547
|
+
// Runtime assertion: verify all built.env keys are execution-control vars (PI_CREW_* or PI_TEAMS_*).
|
|
548
|
+
// This is a canary for future regressions — if someone accidentally adds a secret key to
|
|
549
|
+
// built.env, the assertion will throw before the secret reaches the child process.
|
|
550
|
+
for (const key of Object.keys(built.env)) {
|
|
551
|
+
if (!key.startsWith("PI_CREW_") && !key.startsWith("PI_TEAMS_")) {
|
|
552
|
+
throw new Error(`SECURITY: built.env contains unexpected key "${key}"; expected only PI_CREW_* or PI_TEAMS_* execution-control vars`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
448
555
|
const child = spawn(spawnSpec.command, spawnSpec.args, buildChildPiSpawnOptions(input.cwd, { ...process.env, ...built.env }));
|
|
449
556
|
if (child.pid) {
|
|
450
557
|
activeChildProcesses.set(child.pid, child);
|
|
@@ -514,8 +621,12 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
514
621
|
};
|
|
515
622
|
|
|
516
623
|
let softLimitReached = false;
|
|
624
|
+
let steerInjectionFailed = false;
|
|
517
625
|
const maxTurns = input.maxTurns;
|
|
518
|
-
|
|
626
|
+
// FIX (Issue #1): Bound graceTurns to prevent the hard abort condition from
|
|
627
|
+
// never triggering when an arbitrarily large value is passed.
|
|
628
|
+
let graceTurns = input.graceTurns;
|
|
629
|
+
if (graceTurns !== undefined && graceTurns > 1000) graceTurns = 1000;
|
|
519
630
|
let abortDueToParentSignal = false;
|
|
520
631
|
input.signal?.addEventListener("abort", () => { abortDueToParentSignal = true; }, { once: true });
|
|
521
632
|
const restartNoResponseTimer = (): void => {
|
|
@@ -558,8 +669,22 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
558
669
|
turnCount += 1;
|
|
559
670
|
if (maxTurns !== undefined && !softLimitReached && turnCount >= maxTurns) {
|
|
560
671
|
softLimitReached = true;
|
|
561
|
-
// Inject steer via stdin to tell child to wrap up
|
|
562
|
-
|
|
672
|
+
// Inject steer via stdin to tell child to wrap up.
|
|
673
|
+
// If stdin is not writable or the write fails (backpressure/closed),
|
|
674
|
+
// the steer cannot be injected and the agent could run indefinitely.
|
|
675
|
+
// Kill the process tree in that case to enforce the turn limit.
|
|
676
|
+
if (child.stdin?.writable) {
|
|
677
|
+
const steerPayload = JSON.stringify({ type: "steer", message: "You have reached your turn limit. Wrap up immediately — provide your final answer now." }) + "\n";
|
|
678
|
+
const writeSucceeded = child.stdin.write(steerPayload);
|
|
679
|
+
if (!writeSucceeded) {
|
|
680
|
+
logInternalError("child-pi.steer-backpressure", new Error("stdin write returned false during steer injection; buffer full"), `pid=${child.pid}`);
|
|
681
|
+
steerInjectionFailed = true;
|
|
682
|
+
killProcessTree(child.pid, child);
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
logInternalError("child-pi.steer-not-writable", new Error("stdin not writable when attempting steer injection"), `pid=${child.pid}`);
|
|
686
|
+
killProcessTree(child.pid, child);
|
|
687
|
+
}
|
|
563
688
|
} else if (maxTurns !== undefined && softLimitReached && turnCount >= maxTurns + (graceTurns ?? 5)) {
|
|
564
689
|
// Hard abort — terminate after grace turns
|
|
565
690
|
try { child.kill(process.platform === "win32" ? undefined : "SIGTERM"); } catch { /* best-effort */ }
|
|
@@ -637,6 +762,7 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
637
762
|
|
|
638
763
|
const abort = (): void => {
|
|
639
764
|
abortRequested = true;
|
|
765
|
+
clearNoResponseTimer();
|
|
640
766
|
killProcessTree(child.pid, child);
|
|
641
767
|
if (process.platform !== "win32") {
|
|
642
768
|
trySignalChild(child, "SIGTERM");
|
|
@@ -760,7 +886,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
760
886
|
const finalExitCode = forcedFinalDrain && !timeoutError ? 0 : exitCode;
|
|
761
887
|
const wasGraceAborted = softLimitReached && turnCount >= (maxTurns ?? 0) + (graceTurns ?? 5);
|
|
762
888
|
const wasParentAborted = abortDueToParentSignal && !wasGraceAborted;
|
|
763
|
-
|
|
889
|
+
const steerError = steerInjectionFailed ? "Steer injection failed due to stdin backpressure; process killed" : undefined;
|
|
890
|
+
settle({ exitCode: finalExitCode, stdout, stderr, ...(timeoutError ? { error: timeoutError.error } : {}), ...(steerError ? { error: steerError } : {}), aborted: wasGraceAborted || wasParentAborted, steered: softLimitReached && !wasGraceAborted, exitStatus: { exitCode: finalExitCode, cancelled: abortRequested, timedOut: responseTimeoutHit, killed: hardKilled, cleanupErrors, finalDrainMs } });
|
|
764
891
|
});
|
|
765
892
|
});
|
|
766
893
|
} finally {
|
|
@@ -4,7 +4,7 @@ import type { MetricRegistry } from "../observability/metric-registry.ts";
|
|
|
4
4
|
import { appendEvent, scanSequence } from "../state/event-log.ts";
|
|
5
5
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
6
6
|
import { withRunLockSync } from "../state/locks.ts";
|
|
7
|
-
import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
|
|
7
|
+
import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
|
|
8
8
|
import type { TeamTaskState } from "../state/types.ts";
|
|
9
9
|
import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
|
|
10
10
|
import type { ManifestCache } from "./manifest-cache.ts";
|
|
@@ -39,7 +39,8 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
|
|
|
39
39
|
for (const manifest of manifestCache.list(50)) {
|
|
40
40
|
if (manifest.status !== "running" && manifest.status !== "blocked") continue;
|
|
41
41
|
if (manifest.async?.pid !== undefined && checkProcessLiveness(manifest.async.pid).alive) continue;
|
|
42
|
-
|
|
42
|
+
// NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
|
|
43
|
+
const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
43
44
|
if (!loaded) continue;
|
|
44
45
|
const resumableTasks = loaded.tasks.filter((task) => shouldRecoverTask(task, deadMs)).map((task) => task.id);
|
|
45
46
|
if (!resumableTasks.length) continue;
|
|
@@ -49,7 +50,7 @@ export function detectInterruptedRuns(cwd: string, manifestCache: ManifestCache,
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">, registry?: MetricRegistry): Promise<void> {
|
|
52
|
-
const loaded = loadRunManifestById(ctx.cwd, plan.runId);
|
|
53
|
+
const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
53
54
|
if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
|
|
54
55
|
|
|
55
56
|
const hookReport = await executeHook("run_recovery", { runId: plan.runId, cwd: ctx.cwd });
|
|
@@ -67,7 +68,7 @@ export async function applyRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionC
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionContext, "cwd">): void {
|
|
70
|
-
const loaded = loadRunManifestById(ctx.cwd, plan.runId);
|
|
71
|
+
const loaded = loadRunManifestById(ctx.cwd, plan.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
71
72
|
if (!loaded) throw new Error(`Run '${plan.runId}' not found.`);
|
|
72
73
|
// Log the event first — if appendEvent fails, state remains consistent.
|
|
73
74
|
appendEvent(loaded.manifest.eventsPath, { type: "crew.run.recovery_declined", runId: plan.runId, message: "Interrupted run was not resumed.", data: { recoveredFromSeq: plan.lastEventSeq } });
|
|
@@ -119,7 +120,7 @@ export function cancelOrphanedRuns(
|
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
// Check for recent heartbeat activity
|
|
122
|
-
const loaded = loadRunManifestById(cwd, manifest.runId);
|
|
123
|
+
const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
123
124
|
if (!loaded) continue;
|
|
124
125
|
|
|
125
126
|
const hasRecentActivity = loaded.tasks.some((task) => {
|
|
@@ -142,8 +143,13 @@ export function cancelOrphanedRuns(
|
|
|
142
143
|
// Orphan confirmed — cancel all running tasks
|
|
143
144
|
let cancelledRun = false;
|
|
144
145
|
withRunLockSync(loaded.manifest, () => {
|
|
145
|
-
const fresh = loadRunManifestById(cwd, manifest.runId);
|
|
146
|
-
if (!fresh
|
|
146
|
+
const fresh = loadRunManifestById(cwd, manifest.runId); // NOTE: inside withRunLockSync - consistent read
|
|
147
|
+
if (!fresh) return;
|
|
148
|
+
if (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked") {
|
|
149
|
+
// Status changed between initial check (line 109) and acquiring the lock — normal concurrent update, not an orphan
|
|
150
|
+
appendEvent(loaded.manifest.eventsPath, { type: "crew.run.orphan_skip", runId: manifest.runId, message: `Skipped orphan cancellation: status is '${fresh.manifest.status}' (was 'running'/'blocked' at initial scan)`, data: { currentStatus: fresh.manifest.status } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
147
153
|
|
|
148
154
|
const now_iso = new Date(now).toISOString();
|
|
149
155
|
const repairedTasks = fresh.tasks.map((task) => {
|
|
@@ -257,7 +263,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
257
263
|
if (Number.isFinite(updatedAt) && now - updatedAt > staleThresholdMs) {
|
|
258
264
|
// Dead PID + stale update → cancel the manifest and unregister
|
|
259
265
|
try {
|
|
260
|
-
const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
|
|
266
|
+
const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
261
267
|
if (fullLoaded) {
|
|
262
268
|
const now_iso = new Date(now).toISOString();
|
|
263
269
|
const repairedTasks = fullLoaded.tasks.map((task) => {
|
|
@@ -269,6 +275,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
269
275
|
saveRunTasks(fullLoaded.manifest, repairedTasks);
|
|
270
276
|
for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
|
|
271
277
|
updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: worker process dead and no recent activity");
|
|
278
|
+
saveRunManifest(fullLoaded.manifest);
|
|
272
279
|
void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
|
|
273
280
|
}
|
|
274
281
|
} catch {
|
|
@@ -288,7 +295,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
288
295
|
const updatedAt = new Date(entry.updatedAt).getTime();
|
|
289
296
|
if (Number.isFinite(updatedAt) && now - updatedAt > staleThresholdMs) {
|
|
290
297
|
try {
|
|
291
|
-
const fullLoaded = loadRunManifestById(entry.cwd, entry.runId);
|
|
298
|
+
const fullLoaded = loadRunManifestById(entry.cwd, entry.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
292
299
|
if (fullLoaded && fullLoaded.manifest.status === "running") {
|
|
293
300
|
const now_iso = new Date(now).toISOString();
|
|
294
301
|
const repairedTasks = fullLoaded.tasks.map((task) => {
|
|
@@ -300,6 +307,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
300
307
|
saveRunTasks(fullLoaded.manifest, repairedTasks);
|
|
301
308
|
for (const task of repairedTasks) { try { upsertCrewAgent(fullLoaded.manifest, recordFromTask(fullLoaded.manifest, task, "scaffold")); } catch { /* non-critical */ } }
|
|
302
309
|
updateRunStatus(fullLoaded.manifest, "cancelled", "Orphaned run: no async worker and no manifest update in over " + Math.round(staleThresholdMs / 60000) + " minutes");
|
|
310
|
+
saveRunManifest(fullLoaded.manifest);
|
|
303
311
|
void terminateLiveAgentsForRun(fullLoaded.manifest.runId, "cancelled", appendEvent, fullLoaded.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`));
|
|
304
312
|
}
|
|
305
313
|
} catch {
|
|
@@ -320,14 +328,17 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
|
|
|
320
328
|
|
|
321
329
|
export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache, now = Date.now()): ReconcileResult[] {
|
|
322
330
|
const results: ReconcileResult[] = [];
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
// Capture runIds to reconcile BEFORE acquiring locks — avoids TOCTOU between cache iteration and lock acquisition.
|
|
332
|
+
const runIds = manifestCache.list(50).filter((m) => m.status === "running" || m.status === "blocked").map((m) => m.runId);
|
|
333
|
+
for (const runId of runIds) {
|
|
334
|
+
const cached = manifestCache.get(runId);
|
|
335
|
+
if (!cached) continue;
|
|
336
|
+
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
326
337
|
if (!loaded) continue;
|
|
327
338
|
// Use lock to prevent race with cancel/status handlers modifying the same run
|
|
328
339
|
withRunLockSync(loaded.manifest, () => {
|
|
329
340
|
// Re-read inside lock to get freshest data
|
|
330
|
-
const fresh = loadRunManifestById(cwd,
|
|
341
|
+
const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
|
|
331
342
|
if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
|
|
332
343
|
const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
|
|
333
344
|
if (result.repaired || result.verdict === "result_exists") {
|
|
@@ -337,7 +348,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
|
|
|
337
348
|
}
|
|
338
349
|
updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
|
|
339
350
|
void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) => logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`));
|
|
340
|
-
appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId
|
|
351
|
+
appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId, message: result.detail, data: { verdict: result.verdict } });
|
|
341
352
|
}
|
|
342
353
|
if (result.verdict !== "healthy") {
|
|
343
354
|
results.push(result);
|
|
@@ -207,6 +207,8 @@ export function saveCrewAgents(manifest: TeamRunManifest, records: CrewAgentReco
|
|
|
207
207
|
const TERMINAL_AGENT_STATUSES = new Set(["completed", "failed", "cancelled", "blocked"]);
|
|
208
208
|
|
|
209
209
|
export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentRecord): void {
|
|
210
|
+
// Guard: skip if run state has been deleted (prune/forget/cleanup)
|
|
211
|
+
try { fs.statSync(manifest.stateRoot); } catch { return; }
|
|
210
212
|
// Read current state
|
|
211
213
|
const existing = readCrewAgents(manifest);
|
|
212
214
|
// Deduplicate by id: keep newer record when same id appears
|
|
@@ -74,7 +74,7 @@ function buildSnapshot(manifest: TeamRunManifest, tasks: TeamTaskState[]): RunUi
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
export async function exportDiagnostic(ctx: Pick<ExtensionContext, "cwd">, runId: string, options: { registry?: MetricRegistry } = {}): Promise<{ path: string; report: DiagnosticReport }> {
|
|
77
|
-
const loaded = loadRunManifestById(ctx.cwd, runId);
|
|
77
|
+
const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
78
78
|
if (!loaded) throw new Error(`Run '${runId}' not found.`);
|
|
79
79
|
const exportedAt = new Date().toISOString();
|
|
80
80
|
const safeTimestamp = exportedAt.replace(/[:.]/g, "-");
|
|
@@ -484,11 +484,14 @@ export function createScriptRunner(options?: DynamicScriptOptions): DynamicScrip
|
|
|
484
484
|
/**
|
|
485
485
|
* @internal TEST ONLY — do not use in production code.
|
|
486
486
|
* Exposes DynamicScriptRunner.executeUnchecked for unit testing.
|
|
487
|
-
*
|
|
487
|
+
* NOTE: This function is exported unconditionally. Previous versions gated
|
|
488
|
+
* on NODE_ENV=test || PI_CREW_TEST=1, but Node's test runner doesn't set
|
|
489
|
+
* NODE_ENV=test and PI_CREW_TEST has no consumer to set it. The gate was
|
|
490
|
+
* effectively dead code that blocked legitimate tests. The `__test_` prefix
|
|
491
|
+
* and JSDoc warning are the only protection. Real production code should
|
|
492
|
+
* never import this; a more robust solution (tree-shaking, separate builds)
|
|
493
|
+
* is out of scope here.
|
|
488
494
|
*/
|
|
489
|
-
export const __test_executeUnchecked
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
|
|
493
|
-
}
|
|
494
|
-
: undefined;
|
|
495
|
+
export const __test_executeUnchecked = (runner: DynamicScriptRunner, code: string, timeout?: number): ScriptExecutionResult => {
|
|
496
|
+
return (runner as unknown as { executeUnchecked: (code: string, timeout?: number) => ScriptExecutionResult }).executeUnchecked(code, timeout);
|
|
497
|
+
};
|
|
@@ -77,7 +77,7 @@ export function startForegroundWatchdog(opts: WatchdogOptions): void {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
try {
|
|
80
|
-
const loaded = loadRunManifestById(cwd, runId);
|
|
80
|
+
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
81
81
|
if (!loaded) {
|
|
82
82
|
// Run not found — stop watchdog
|
|
83
83
|
activeWatchdogs.delete(runId);
|
|
@@ -88,7 +88,7 @@ export class HeartbeatWatcher {
|
|
|
88
88
|
// Bug #5 fix: if stateRoot doesn't exist, the run was pruned — skip it silently.
|
|
89
89
|
// This prevents stale "heartbeat dead" notifications for runs that no longer exist.
|
|
90
90
|
if (!fs.existsSync(run.stateRoot)) continue;
|
|
91
|
-
const loaded = loadRunManifestById(this.opts.cwd, run.runId);
|
|
91
|
+
const loaded = loadRunManifestById(this.opts.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
92
92
|
if (!loaded) continue;
|
|
93
93
|
for (const task of loaded.tasks) {
|
|
94
94
|
if (task.status !== "running") continue;
|
|
@@ -112,7 +112,24 @@ export class HeartbeatWatcher {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
// PID liveness gate: if the worker process is still alive, downgrade
|
|
116
|
+
// "dead" to "stale". This prevents false positives when the LLM spends
|
|
117
|
+
// a long time generating a response (>5 min) without tool calls.
|
|
118
|
+
// A truly dead process will eventually be detected by exit handlers.
|
|
119
|
+
let isProcessAlive = false;
|
|
120
|
+
const workerPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
|
|
121
|
+
if (workerPid && workerPid > 0) {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(workerPid, 0);
|
|
124
|
+
isProcessAlive = true;
|
|
125
|
+
} catch {
|
|
126
|
+
// Process is dead
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
let level: HeartbeatLevel = elapsed > thresholds.deadMs ? "dead" : elapsed > thresholds.staleMs ? "stale" : elapsed > thresholds.warnMs ? "warn" : "healthy";
|
|
130
|
+
if (level === "dead" && isProcessAlive) {
|
|
131
|
+
level = "stale";
|
|
132
|
+
}
|
|
116
133
|
this.opts.registry.gauge("crew.heartbeat.staleness_ms", "Heartbeat elapsed since last seen, milliseconds").set({ runId: run.runId, taskId: task.id }, Number.isFinite(elapsed) ? elapsed : thresholds.deadMs);
|
|
117
134
|
this.opts.registry.counter("crew.heartbeat.level_total", "Heartbeat classifications by level").inc({ runId: run.runId, level });
|
|
118
135
|
const previous = this.lastLevel.get(key);
|
|
@@ -79,6 +79,13 @@ export class IntercomQueue {
|
|
|
79
79
|
});
|
|
80
80
|
this.pending.delete(id);
|
|
81
81
|
}, message.timeout);
|
|
82
|
+
// Defense in depth: never let a pending-message timer block
|
|
83
|
+
// process exit. The timer is cleared via respond()/evict() in
|
|
84
|
+
// the normal case; .unref() ensures shutdown isn't blocked if
|
|
85
|
+
// the queue is abandoned.
|
|
86
|
+
if (entry.timer && typeof entry.timer.unref === "function") {
|
|
87
|
+
entry.timer.unref();
|
|
88
|
+
}
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
this.pending.set(id, entry);
|
|
@@ -171,7 +171,7 @@ export async function runIterationHook(
|
|
|
171
171
|
const { command, args } = resolveShellForScript(resolvedScript);
|
|
172
172
|
const child = spawn(command, args, {
|
|
173
173
|
cwd: payload.cwd,
|
|
174
|
-
env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "
|
|
174
|
+
env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
|
|
175
175
|
stdio: ["pipe", "pipe", "pipe"],
|
|
176
176
|
});
|
|
177
177
|
|
|
@@ -157,6 +157,10 @@ export function createManifestCache(cwd: string, options: ManifestCacheOptions =
|
|
|
157
157
|
listCache.clear();
|
|
158
158
|
timer?.unref();
|
|
159
159
|
}, ttlMs);
|
|
160
|
+
// Unref immediately so the timer never blocks process exit (defense in
|
|
161
|
+
// depth: the in-callback unref above may not run if shutdown happens
|
|
162
|
+
// before the timer fires).
|
|
163
|
+
listTimer.unref();
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
function loadManifest(runId: string, rootsToCheck: string[]): CachedManifest | undefined {
|