pi-crew 0.11.4 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +2 -2
  3. package/dist/index.mjs +498 -492
  4. package/docs/actions-reference.md +22 -0
  5. package/docs/dynamic-workflows.md +37 -0
  6. package/index.ts +9 -5
  7. package/package.json +18 -14
  8. package/scripts/analyze-run.mjs +101 -14
  9. package/skills/model-routing-context/SKILL.md +4 -3
  10. package/skills/real-test-pi-crew/SKILL.md +10 -7
  11. package/src/config/env-vars.ts +6 -0
  12. package/src/extension/crew-vibes/index.ts +29 -10
  13. package/src/extension/pi-api.ts +1 -0
  14. package/src/extension/registration/lifecycle-handlers.ts +65 -3
  15. package/src/extension/team-tool/dispatch/index.ts +4 -3
  16. package/src/extension/team-tool/dispatch/status.ts +7 -3
  17. package/src/extension/team-tool/inspect.ts +90 -0
  18. package/src/extension/team-tool/respond.ts +54 -0
  19. package/src/extension/team-tool/status.ts +10 -0
  20. package/src/extension/team-tool.ts +393 -3
  21. package/src/extension/webhook-notify.ts +23 -1
  22. package/src/observability/exporters/otlp-exporter.ts +93 -11
  23. package/src/prompt/prompt-runtime.ts +13 -4
  24. package/src/runtime/agent-control.ts +1 -15
  25. package/src/runtime/background-runner.ts +3 -0
  26. package/src/runtime/broker/crew-broker.ts +12 -103
  27. package/src/runtime/broker/protocol/request-parsers.ts +9 -1
  28. package/src/runtime/broker/protocol/wait-resolve.ts +168 -0
  29. package/src/runtime/child-pi/child-pi-spawn.ts +5 -4
  30. package/src/runtime/dispatch-batch.ts +3 -1
  31. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +111 -12
  32. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +19 -0
  33. package/src/runtime/live-session/live-agent-manager.ts +35 -0
  34. package/src/runtime/live-session/live-session-runtime.ts +34 -5
  35. package/src/runtime/mcp-proxy.ts +110 -7
  36. package/src/runtime/model/pi-args.ts +14 -11
  37. package/src/runtime/model/provider-extensions.ts +67 -1
  38. package/src/runtime/recovery/crash-recovery.ts +6 -1
  39. package/src/runtime/scheduling/run-coalesced-task-group.ts +89 -6
  40. package/src/runtime/stale-reconciler.ts +55 -0
  41. package/src/runtime/task-output-context.ts +11 -1
  42. package/src/runtime/task-runner/child-executor.ts +130 -4
  43. package/src/runtime/task-runner/output-splitter.ts +48 -5
  44. package/src/runtime/task-runner/post-execution.ts +2 -2
  45. package/src/runtime/task-runner/pre-execution.ts +37 -24
  46. package/src/runtime/task-runner/retrieval-orchestrator.ts +28 -2
  47. package/src/schema/team-tool-schema.ts +27 -1
  48. package/src/state/contracts.ts +16 -0
  49. package/src/state/crew-init.ts +114 -0
  50. package/src/state/stores/active-run-registry.ts +13 -6
  51. package/src/state/types.ts +0 -8
  52. package/src/ui/goal-flag.ts +44 -0
  53. package/src/ui/live-run-sidebar.ts +5 -1
  54. package/src/ui/pi-ui-compat.ts +20 -3
  55. package/src/ui/run-dashboard.ts +2 -1
  56. package/src/ui/widget/widget-model.ts +6 -2
  57. package/src/utils/ip-literal.ts +145 -0
  58. package/src/workflows/workflow-config.ts +6 -0
@@ -62,6 +62,61 @@ function cachedResult(settingsPath: string): { mtimeMs: number; result: Discover
62
62
  return undefined;
63
63
  }
64
64
 
65
+ /**
66
+ * GH #61: normalize a DIRECTORY `pi.extensions` entry to the entry FILE that
67
+ * Pi's own package auto-load registers. Pi's chain (package-manager
68
+ * `collectAutoExtensionEntries` → `resolveExtensionEntries`) resolves a
69
+ * directory to its nested pi-manifest entries, else `index.ts`/`index.js`.
70
+ * If pi-crew injects the directory as-is, the child worker loads the same
71
+ * extension twice under two path identities (".../dist" via --extension vs
72
+ * ".../dist/index.js" via package auto-discovery) and pi's
73
+ * detectExtensionConflicts kills the worker at startup (exit 1, every tool
74
+ * reported as conflicting). Always inject the FILE identity.
75
+ */
76
+ function resolveExtensionDirEntry(dir: string, depth = 0): string | undefined {
77
+ if (depth > 2) return undefined; // guard pathological nesting loops
78
+ const pkgJsonPath = path.join(dir, "package.json");
79
+ if (fs.existsSync(pkgJsonPath)) {
80
+ try {
81
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { pi?: { extensions?: string[] } };
82
+ const piExts = pkg.pi?.extensions;
83
+ if (Array.isArray(piExts) && piExts.length > 0) {
84
+ for (const rel of piExts) {
85
+ const abs = path.resolve(dir, rel);
86
+ if (!fs.existsSync(abs)) continue;
87
+ return fs.statSync(abs).isDirectory() ? resolveExtensionDirEntry(abs, depth + 1) : abs;
88
+ }
89
+ }
90
+ } catch {
91
+ /* malformed package.json — fall through to index probes */
92
+ }
93
+ }
94
+ // Order mirrors Pi's resolveExtensionEntries EXACTLY for the first two
95
+ // (index.ts → index.js): when Pi's auto-load registers the extension, the
96
+ // injected identity MUST match its pick. index.mjs and package.json `main`
97
+ // are fallbacks for packages Pi's auto-load would NOT register at all (it
98
+ // checks neither) — a single injected instance there cannot conflict, and
99
+ // `main` preserves the legacy jiti dir-loading behaviour (GH #61 follow-up).
100
+ for (const name of ["index.ts", "index.js"]) {
101
+ const abs = path.join(dir, name);
102
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
103
+ }
104
+ const mjs = path.join(dir, "index.mjs");
105
+ if (fs.existsSync(mjs) && fs.statSync(mjs).isFile()) return mjs;
106
+ if (fs.existsSync(pkgJsonPath)) {
107
+ try {
108
+ const main = (JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as { main?: string }).main;
109
+ if (main) {
110
+ const abs = path.resolve(dir, main);
111
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return abs;
112
+ }
113
+ } catch {
114
+ /* malformed package.json — skip */
115
+ }
116
+ }
117
+ return undefined;
118
+ }
119
+
65
120
  /**
66
121
  * Resolve the extension entry point for an installed Pi package.
67
122
  * Order: package.json `pi.extensions` (array) → `index.ts` → `index.mjs` → `index.js` → `src/index.ts`.
@@ -80,7 +135,18 @@ function resolvePackageEntry(pkgDir: string): string | undefined {
80
135
  if (Array.isArray(piExts) && piExts.length > 0) {
81
136
  for (const rel of piExts) {
82
137
  const abs = path.resolve(pkgDir, rel);
83
- if (fs.existsSync(abs)) return abs;
138
+ if (!fs.existsSync(abs)) continue;
139
+ // GH #61: a DIRECTORY entry (e.g. pi-web-access "./dist") must be
140
+ // normalized to the file identity Pi's auto-load uses — injecting the
141
+ // raw dir double-loads the extension in child workers (tool conflicts,
142
+ // exit 1). An unresolvable dir is SKIPPED, matching Pi's own view of
143
+ // the package (its auto-load would not register it either).
144
+ if (fs.statSync(abs).isDirectory()) {
145
+ const entry = resolveExtensionDirEntry(abs);
146
+ if (entry) return entry;
147
+ continue;
148
+ }
149
+ return abs;
84
150
  }
85
151
  }
86
152
  // Fall back to `main` when it points at a loadable entry (rare for extensions).
@@ -750,7 +750,12 @@ export async function reconcileAllStaleRuns(
750
750
  const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
751
751
  if (result.repaired || result.verdict === "result_exists") {
752
752
  if (result.repairedTasks) {
753
- saveRunTasks(fresh.manifest, result.repairedTasks);
753
+ // NEW-1 (SDD 2026-09-30 WI-2): persist the FULL task array — the
754
+ // individual-stale branch filters `repairedTasks` down to the stale
755
+ // subset, and saveRunTasks full-overwrites tasks.json, which used to
756
+ // drop every healthy task from disk. `persistTasks` carries the full
757
+ // array; every other branch leaves it unset and falls back here.
758
+ saveRunTasks(fresh.manifest, result.persistTasks ?? result.repairedTasks);
754
759
  for (const task of result.repairedTasks) {
755
760
  try {
756
761
  upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
@@ -102,6 +102,18 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
102
102
  // (now-terminal) updatedTasks AFTER its own save resolves, so the terminal
103
103
  // state always lands last regardless of disk timing.
104
104
  let finalWriteStarted = false;
105
+ // NEW-3 (SDD-4 W-D): the coalesced path was the only dispatch path with NO
106
+ // pid evidence at all — the singleton path records checkpoint.childPid at
107
+ // child-spawned (child-executor.ts), but this module never calls
108
+ // checkpointTask, so heartbeat.pid stayed undefined AND checkpoint.childPid
109
+ // stayed undefined → the PID-liveness gates in stale-reconciler.ts and
110
+ // heartbeat-watcher.ts (both read `heartbeat?.pid ?? checkpoint?.childPid`)
111
+ // resolved to undefined for EVERY task in EVERY coalesced group; an
112
+ // alive-but-silent coalesced worker could be falsely repaired. Capture the
113
+ // real worker pid at spawn — onSpawn fires per spawn attempt, so
114
+ // retry/model-fallback overwrite this with the latest live pid (mirroring
115
+ // the singleton ownership-entry semantics in child-executor.ts).
116
+ let workerPid: number | undefined;
105
117
  if (!executeWorkers) {
106
118
  rawOutput = buildScaffoldOutput(groupTasks);
107
119
  success = true;
@@ -121,7 +133,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
121
133
  // snapshot of the FULL task array — a sibling cancelled on disk after
122
134
  // dispatch could be un-cancelled by a late heartbeat save (the map
123
135
  // only mutated group tasks, but the SAVE wrote the whole stale array).
124
- await persistGroupHeartbeats(manifest, taskIds);
136
+ await persistGroupHeartbeats(manifest, taskIds, workerPid);
125
137
  } catch {
126
138
  // Run may have been pruned mid-dispatch — best-effort only.
127
139
  } finally {
@@ -188,6 +200,11 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
188
200
  maxTurns: input.runtimeConfig?.maxTurns,
189
201
  graceTurns: input.runtimeConfig?.graceTurns,
190
202
  onJsonEvent: (e) => input.onJsonEvent?.(firstTask.id, manifest.runId, e),
203
+ // NEW-3: capture the spawned worker's real pid for the heartbeat
204
+ // persists (see `workerPid` declaration above).
205
+ onSpawn: (pid) => {
206
+ workerPid = pid;
207
+ },
191
208
  });
192
209
  } finally {
193
210
  if (timeoutHandle) clearTimeout(timeoutHandle);
@@ -246,13 +263,70 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
246
263
 
247
264
  const split = splitCoalescedOutput(rawOutput, taskIds);
248
265
 
266
+ // G20 (2026-10-02): fail-closed partial delimiter-hit. When the splitter
267
+ // reports the output as unpartitionable (strategy "deadletter" — the
268
+ // worker delimited SOME but not ALL of the group's tasks), broadcasting
269
+ // the full raw output to every task used to leak each task's delimited
270
+ // body to every OTHER task. Instead, record the group in the deadletter
271
+ // store using the same deliberate US-003 pattern as the retry-exhaustion
272
+ // hook above: ONE entry against the group's FIRST task, with the whole
273
+ // group's taskIds named in the message — the group shares one worker, so
274
+ // a single post-mortem record is the deliberate granularity. Each task
275
+ // then gets a content-free marker and a terminal "failed" status (see the
276
+ // mapping loop below); no task receives another task's content.
277
+ const deadlettered = split.some((s) => s.strategy === "deadletter");
278
+ if (deadlettered) {
279
+ appendDeadletter(manifest, {
280
+ runId: manifest.runId,
281
+ taskId: firstTask.id,
282
+ reason: "manual",
283
+ attempts: 1,
284
+ lastError: `coalesced group (${taskIds.length} task(s): ${taskIds.join(", ")}) — unpartitionable worker output: partial delimiter hit; per-task results withheld (G20 fail-closed, cross-task broadcast suppressed)`,
285
+ timestamp: new Date().toISOString(),
286
+ agent: agent.name,
287
+ role: firstTask.role,
288
+ runStatus: manifest.status,
289
+ });
290
+ }
291
+
249
292
  const finishedAt = new Date().toISOString();
250
293
  const newArtifacts: TeamRunManifest["artifacts"] = [];
251
294
  updatedTasks = updatedTasks.map((t) => {
252
295
  if (!taskIds.includes(t.id)) return t;
253
296
  const entry = split.find((s) => s.taskId === t.id);
297
+ // G20 deadletter branch: unpartitionable output. Fail closed — every
298
+ // group task gets a marker that carries NO cross-task content (no
299
+ // delimited bodies, no full raw output) and a terminal "failed" status
300
+ // regardless of the worker's exit code: a worker whose output cannot be
301
+ // partitioned has NOT produced this task's result.
302
+ if (deadlettered) {
303
+ const markerText = `[coalesced-output-unpartitionable] task ${t.id}: worker output could not be partitioned (partial delimiter hit) — per-task result withheld (G20 fail-closed); see deadletter entry for group ${groupId}`;
304
+ const resultArtifact = writeArtifact(manifest.artifactsRoot, {
305
+ kind: "result",
306
+ relativePath: `results/${t.id}.txt`,
307
+ content: markerText,
308
+ producer: t.id,
309
+ });
310
+ newArtifacts.push(resultArtifact);
311
+ return {
312
+ ...t,
313
+ status: "failed" as const,
314
+ finishedAt,
315
+ result: {
316
+ text: markerText,
317
+ producer: groupId,
318
+ strategy: "deadletter",
319
+ },
320
+ resultArtifact,
321
+ };
322
+ }
254
323
  const ok = success && Boolean(entry?.text);
255
- const text = entry?.text ?? rawOutput;
324
+ // G20 (second leak path closed): a task with NO split entry previously
325
+ // fell back to the FULL rawOutput — i.e. every other task's content.
326
+ // That fallback was exactly the cross-task leak; a missing entry now
327
+ // gets an empty marker instead. (splitCoalescedOutput returns an entry
328
+ // per taskId in every branch today, so this is defensive.)
329
+ const text = entry?.text ?? `[no partition entry for task ${t.id} — result withheld]`;
256
330
  // BUGFIX (M6 real dispatch): write to artifactsRoot via writeArtifact
257
331
  // so task.resultArtifact is set and aggregateTaskOutputs can read
258
332
  // the per-task text. Previously the coalesced path used a raw
@@ -315,8 +389,10 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
315
389
  await appendEventAsync(updatedManifest.eventsPath, {
316
390
  type: "task.coalesced_dispatch_end",
317
391
  runId: manifest.runId,
318
- message: `Coalesced dispatch ${success ? "completed" : cancelled ? "cancelled" : "failed"} (${taskIds.length} tasks, ${split[0]?.strategy ?? "broadcast"} split)`,
319
- data: { groupId, taskIds, success, cancelled, strategy: split[0]?.strategy },
392
+ message: deadlettered
393
+ ? `Coalesced dispatch failed — unpartitionable output deadlettered (${taskIds.length} tasks, partial delimiter hit, G20 fail-closed)`
394
+ : `Coalesced dispatch ${success ? "completed" : cancelled ? "cancelled" : "failed"} (${taskIds.length} tasks, ${split[0]?.strategy ?? "broadcast"} split)`,
395
+ data: { groupId, taskIds, success, cancelled, strategy: split[0]?.strategy, deadlettered },
320
396
  });
321
397
 
322
398
  return { manifest: updatedManifest, tasks: updatedTasks, taskIds, rawOutput, success };
@@ -335,7 +411,7 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
335
411
  * with heartbeats touched; sibling tasks are preserved untouched from the
336
412
  * fresh disk read. Keeps the FIND-06 'never replace terminal state' guard.
337
413
  */
338
- async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[]): Promise<void> {
414
+ async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string[], workerPid?: number): Promise<void> {
339
415
  const fresh = loadRunManifestById(manifest.cwd, manifest.runId);
340
416
  if (!fresh) return; // run pruned mid-dispatch — best-effort only.
341
417
  const merged = fresh.tasks.map((t) => {
@@ -345,7 +421,14 @@ async function persistGroupHeartbeats(manifest: TeamRunManifest, taskIds: string
345
421
  if (t.status === "completed" || t.status === "failed" || t.status === "cancelled") return t;
346
422
  return {
347
423
  ...t,
348
- heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id), { alive: true }),
424
+ // NEW-3: stamp the worker's real pid on BOTH the create fallback and
425
+ // the touch update. Conditional spread is required — a literal
426
+ // `pid: undefined` would clobber a pid recorded by an earlier tick
427
+ // (touchWorkerHeartbeat spreads `updates` verbatim).
428
+ heartbeat: touchWorkerHeartbeat(t.heartbeat ?? createWorkerHeartbeat(t.id, workerPid), {
429
+ alive: true,
430
+ ...(workerPid ? { pid: workerPid } : {}),
431
+ }),
349
432
  };
350
433
  });
351
434
  await saveRunTasksAsync(manifest, merged);
@@ -4,6 +4,7 @@ import * as path from "node:path";
4
4
  import { getCrewEnv } from "../config/env-vars.ts";
5
5
  import { errors } from "../errors.ts";
6
6
  import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
7
+ import { type ActiveRunRegistryEntry, activeRunEntries } from "../state/stores/active-run-registry.ts";
7
8
  import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
8
9
  import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
9
10
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -44,6 +45,13 @@ export interface ReconcileResult {
44
45
  detail: string;
45
46
  /** Repaired task state, returned to a locked caller for persistence. */
46
47
  repairedTasks?: TeamTaskState[];
48
+ /** NEW-1 (SDD 2026-09-30 WI-2): what the caller should PERSIST — the FULL
49
+ * task array, including tasks the repair did not touch. Only the
50
+ * individual-stale branch sets it (its `repairedTasks` is filtered down to
51
+ * the stale subset, which must NOT be full-overwrite-persisted or healthy
52
+ * tasks vanish from tasks.json). Callers fall back to `repairedTasks`
53
+ * when absent — every other branch already returns the full array there. */
54
+ persistTasks?: TeamTaskState[];
47
55
  }
48
56
 
49
57
  /**
@@ -353,6 +361,25 @@ function buildStaleReconcileError(task: TeamTaskState, reason: string): Error {
353
361
  return errors.runStale(reason, heartbeatAgeSeconds);
354
362
  }
355
363
 
364
+ /**
365
+ * G24 (SDD-4 WI-2): find a LIVE active-run-registry entry for this runId.
366
+ * activeRunEntries() already applies the full liveness filter (terminal status,
367
+ * dead async PID, >30min-stale non-async manifests, symlink safety), so an
368
+ * entry found here means some live session or runner claims the run RIGHT NOW.
369
+ * Read-only consult (register/unregister are never imported here). Fail-open:
370
+ * on an unexpected registry error return undefined so normal staleness
371
+ * reconciliation proceeds — a broken registry must not shield dead runs from
372
+ * repair forever.
373
+ */
374
+ function findLiveRegistryEntry(runId: string): ActiveRunRegistryEntry | undefined {
375
+ try {
376
+ return activeRunEntries().find((entry) => entry.runId === runId);
377
+ } catch (err) {
378
+ logInternalError("stale-reconciler", new Error(`active-run registry consult failed for ${runId}: ${err}`), undefined, "warn");
379
+ return undefined;
380
+ }
381
+ }
382
+
356
383
  function repairStaleRun(manifest: TeamRunManifest, tasks: TeamTaskState[], reason: string): TeamTaskState[] {
357
384
  const now = new Date().toISOString();
358
385
  const repairedTasks = tasks.map((task) => {
@@ -425,6 +452,33 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
425
452
  };
426
453
  }
427
454
 
455
+ // G24 (SDD-4 WI-2, plan §2 upgrade-2026-09-29): foreign-LIVE skip. The
456
+ // active-run registry is the cross-session source of truth for "a live
457
+ // session/runner owns this run right now" (registerActiveRun at dispatch;
458
+ // entries self-expire via PID liveness + the 30min freshness horizon). Until
459
+ // now ownership was only checked as "run of the CURRENT session"
460
+ // (crash-recovery.ts ownerSessionId filter), so a child session — or the /tmp
461
+ // orphan scan, which has no session identity at all — could repair (cancel)
462
+ // a run whose workers were alive but heartbeat-frozen (G25 silent turn:
463
+ // 12m27s measured on a live worker). Must run BEFORE Phase 1/2/3: no phase
464
+ // below may act on a run a live owner still owns. Placement AFTER the
465
+ // intentional-wait guards keeps their more precise verdicts (both already
466
+ // return repaired:false, so no repair was ever at risk from them).
467
+ const liveEntry = findLiveRegistryEntry(runId);
468
+ if (liveEntry) {
469
+ return {
470
+ runId,
471
+ verdict: "healthy",
472
+ repaired: false,
473
+ // Honest text (G24): says exactly what happened — skipped because a live
474
+ // owner exists — and does not claim any repair. The "healthy" verdict
475
+ // keeps callers passive: crash-recovery does not push it into the
476
+ // notify result list, and reconcileOrphanedTempWorkspaces marks the
477
+ // workspace hasRunning so the foreign run's state is preserved.
478
+ detail: `Foreign-LIVE run: active-run registry entry is alive (cwd ${liveEntry.cwd}, updatedAt ${liveEntry.updatedAt}); reconcile skipped — a live session/runner owns this run, nothing was changed`,
479
+ };
480
+ }
481
+
428
482
  // Phase 1: Check if results already exist
429
483
  const phase1 = checkResultFile(manifest, tasks);
430
484
  if (phase1.found) {
@@ -490,6 +544,7 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
490
544
  repaired: true,
491
545
  detail: `No PID; ${staleTaskIds.length} individually stale task(s) repaired: ${staleTaskIds.join(", ")}`,
492
546
  repairedTasks: repaired.filter((t) => staleTaskIds.includes(t.id)),
547
+ persistTasks: repaired, // NEW-1: persist FULL array (data-loss fix — SDD WI-2)
493
548
  };
494
549
  }
495
550
  // Fall through: no recent activity but not all tasks stale enough yet.
@@ -526,6 +526,16 @@ export function collectDependencyOutputContext(
526
526
  return { dependencies: trimmedDependencies, sharedReads };
527
527
  }
528
528
 
529
+ // ADR-5 §trust-fence: dependency output is DATA, never instructions. Mirror the
530
+ // ask/delegate seams in src/prompt/prompt-runtime.ts (:323, :426, :589): strip
531
+ // control chars and neutralize a smuggled closing fence tag so worker-controlled
532
+ // content can never close the <dependency-context> fence early (see SDD 2026-09-30 WI-1).
533
+ const DEPENDENCY_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
534
+
535
+ function sanitizeFencedBody(body: string): string {
536
+ return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
537
+ }
538
+
529
539
  export function renderDependencyOutputContext(context: DependencyOutputContext): string {
530
540
  const parts: string[] = [];
531
541
  if (context.dependencies.length) {
@@ -565,7 +575,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
565
575
  parts.push("", read.content.trim(), "");
566
576
  }
567
577
  }
568
- return parts.join("\n").trim();
578
+ return sanitizeFencedBody(parts.join("\n").trim());
569
579
  }
570
580
 
571
581
  export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
@@ -28,7 +28,7 @@
28
28
  import * as fs from "node:fs";
29
29
  import * as path from "node:path";
30
30
  import { loadConfig } from "../../config/config.ts";
31
- import { getCrewEnv } from "../../config/env-vars.ts";
31
+ import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
32
32
  import { errors } from "../../errors.ts";
33
33
  import { appendEventAsync, appendEventBuffered } from "../../state/event-log/event-log.ts";
34
34
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
@@ -64,6 +64,7 @@ import { readEnabledModelsPatterns } from "../model/model-scope.ts";
64
64
  import { type ParsedPiJsonOutput, parsePiJsonOutput } from "../output/pi-json-output.ts";
65
65
  import { type ProgressEventSummary, shouldAppendProgressEventUpdate } from "../output/progress-event-coalescer.ts";
66
66
  import { buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "../process/cancellation.ts";
67
+ import { checkProcessLiveness } from "../process-status.ts";
67
68
  import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
68
69
  import { runWorker } from "../run-worker.ts";
69
70
  import { parseSessionUsage } from "../session-usage.ts";
@@ -152,6 +153,90 @@ export function computeSpawnBudgetMax(attemptModelsCount: number, configuredMaxA
152
153
  return attemptModelsCount * (configuredMaxAttempts + 1);
153
154
  }
154
155
 
156
+ /**
157
+ * NEW-4/G25 (SDD-4 WI-4): default liveness-pulse interval, in ms.
158
+ *
159
+ * Why a pulse at all: `persistHeartbeat` was only invoked from stdout-driven
160
+ * callbacks (onStdoutLine / onJsonEvent / onSurfaceActivity). A worker turn
161
+ * that stays silent for longer than the stale windows — observed live:
162
+ * 12m27s (heartbeat gradient deadMs=300s, staleMs=60s; stale-reconciler
163
+ * NO_PID_HEARTBEAT_STALE_MS=300s) — froze heartbeat.lastSeenAt while the
164
+ * worker process was alive and mid-turn, so liveness consumers (heartbeat
165
+ * watcher gradient, widget, stale reconciler no-PID path) saw a "dead"
166
+ * worker that was actually healthy.
167
+ *
168
+ * 15s keeps the gradient at "healthy" (warnMs=30s) with 2× headroom, stays
169
+ * 4× under staleMs (60s) and 20× under deadMs (300s), and costs at most one
170
+ * throttled tasks.json persist per running task per 15s (persistHeartbeat's
171
+ * 1s throttle binds first for tighter configured intervals).
172
+ *
173
+ * Design choice (producer-side pulse vs reconciler corroboration): the
174
+ * reconciler and heartbeat-watcher ALREADY carry a PID-liveness gate
175
+ * (stale-reconciler.ts isTaskHeartbeatStale, heartbeat-watcher.ts
176
+ * dead→stale downgrade) keyed on `heartbeat.pid ?? checkpoint.childPid`.
177
+ * Those gates only help when a pid was recorded AND the consumer runs it;
178
+ * the pulse fixes the PRODUCER side for every consumer at once (gradient
179
+ * levels, widget, no-pid repair path, ambient notifications) without
180
+ * touching stale-reconciler.ts (owned by WI-2). Both are wanted; this is
181
+ * the least invasive in-lane half. See handoff for the recommendation.
182
+ */
183
+ const DEFAULT_HEARTBEAT_PULSE_INTERVAL_MS = 15_000;
184
+
185
+ /** Opaque handle for the per-attempt heartbeat liveness pulse. */
186
+ export interface WorkerHeartbeatPulse {
187
+ /** Stop the pulse and clear its timer. Idempotent. */
188
+ stop(): void;
189
+ }
190
+
191
+ /**
192
+ * Resolve the liveness-pulse interval from `PI_CREW_HEARTBEAT_PULSE_MS`.
193
+ * Unset/invalid → default (15s). Values ≤ 0 disable the pulse entirely
194
+ * (escape hatch for operators/batteries that want the pre-fix behaviour).
195
+ */
196
+ export function resolveHeartbeatPulseIntervalMs(): number {
197
+ const configured = getCrewEnvInt("PI_CREW_HEARTBEAT_PULSE_MS");
198
+ if (configured === undefined || !Number.isFinite(configured)) return DEFAULT_HEARTBEAT_PULSE_INTERVAL_MS;
199
+ if (configured <= 0) return 0;
200
+ return configured;
201
+ }
202
+
203
+ /**
204
+ * Start a periodic heartbeat liveness pulse: every `intervalMs`, if
205
+ * `isAlive()` reports the worker incarnation as alive, call `touch()`.
206
+ * This decouples heartbeat freshness from stdout events (NEW-4/G25): a
207
+ * silent-but-alive turn keeps the heartbeat fresh; a dead worker stops
208
+ * being touched the moment its pid checks dead (see [pulse-2]).
209
+ *
210
+ * Failure containment: a throwing `touch`/`isAlive` is logged and the timer
211
+ * keeps ticking — the pulse must never crash the host process ([pulse-3]).
212
+ * The timer is `unref`'d so it can never hold the event loop open.
213
+ */
214
+ export function startWorkerHeartbeatPulse(opts: { intervalMs: number; isAlive: () => boolean; touch: () => void }): WorkerHeartbeatPulse {
215
+ if (opts.intervalMs <= 0) {
216
+ return {
217
+ stop() {
218
+ // Intentional no-op: a disabled pulse (interval ≤ 0) has no timer to clear.
219
+ },
220
+ };
221
+ }
222
+ let stopped = false;
223
+ const timer = setInterval(() => {
224
+ if (stopped) return;
225
+ try {
226
+ if (opts.isAlive()) opts.touch();
227
+ } catch (err) {
228
+ logInternalError("task-runner.heartbeat-pulse", err as Error);
229
+ }
230
+ }, opts.intervalMs);
231
+ timer.unref?.();
232
+ return {
233
+ stop() {
234
+ stopped = true;
235
+ clearInterval(timer);
236
+ },
237
+ };
238
+ }
239
+
155
240
  /**
156
241
  * Resolve the model fallback policy from crew config + env. Best-effort: any
157
242
  * config read failure returns undefined (legacy unbounded/unordered behaviour).
@@ -442,12 +527,16 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
442
527
  // is informational and re-derivable from per-agent records.
443
528
  // appendEventBuffered coalesces into a single lock acquire after bufferMs,
444
529
  // reducing producer p95 from ~13µs (serial) to ~0µs (bench M7).
530
+ // .catch is REQUIRED (CI 2026-10-03 char-core5-6): the buffered flush
531
+ // rejects queued promises on write failure (e.g. ENOENT after cwd
532
+ // cleanup) — a naked `void` turns that into an unhandledRejection
533
+ // that kills the host test/process long after the caller returned.
445
534
  void appendEventBuffered(manifest.eventsPath, {
446
535
  type: "task.progress",
447
536
  runId: manifest.runId,
448
537
  taskId: task.id,
449
538
  data: { ...summary, coalesceReason: decision.reason },
450
- });
539
+ }).catch((error) => logInternalError("child-executor.progress-buffered", error, `taskId=${task.id}, runId=${manifest.runId}`));
451
540
  lastRunProgressSummary = summary;
452
541
  lastRunProgressPersistedAt = now;
453
542
  }
@@ -535,6 +624,30 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
535
624
  }, taskTimeoutMs);
536
625
  timeoutHandle.unref?.();
537
626
  }
627
+ // NEW-4/G25 (SDD-4 WI-4): per-attempt heartbeat liveness pulse. Keeps
628
+ // heartbeat.lastSeenAt fresh while THIS attempt's worker process is
629
+ // alive, independent of stdout events (a silent turn no longer freezes
630
+ // the heartbeat → liveness consumers stop repairing live workers).
631
+ // Liveness predicate:
632
+ // - attempt aborted (wall-clock timeout / external cancel) → stop
633
+ // asserting liveness; kill escalation owns the remainder.
634
+ // - pid known (onSpawn fired) → hard corroboration via kill(pid,0);
635
+ // a dead pid is never touched, so genuine zombie repair keeps working.
636
+ // - pid not yet known (pre-spawn window / mock fixtures that bypass
637
+ // spawn) → the pending runWorker promise is the best available
638
+ // evidence of an in-flight incarnation; touch (fail-open, bounded:
639
+ // real spawns pin the pid within ms, and the reconciler's ESRCH
640
+ // verdict can never be overridden by a fresh heartbeat).
641
+ let attemptWorkerPid: number | undefined;
642
+ const heartbeatPulse = startWorkerHeartbeatPulse({
643
+ intervalMs: resolveHeartbeatPulseIntervalMs(),
644
+ isAlive: () => {
645
+ if (timeoutController.signal.aborted) return false;
646
+ if (attemptWorkerPid === undefined) return true;
647
+ return checkProcessLiveness(attemptWorkerPid).alive;
648
+ },
649
+ touch: () => persistHeartbeat(),
650
+ });
538
651
  let childResult: ChildPiRunResult;
539
652
  try {
540
653
  childResult = await runWorker({
@@ -561,6 +674,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
561
674
  attempt: i,
562
675
  steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
563
676
  onSpawn: (pid) => {
677
+ // NEW-4/G25: pin this incarnation's pid FIRST so the liveness pulse
678
+ // corroborates against the real process from its first tick on.
679
+ attemptWorkerPid = pid;
564
680
  try {
565
681
  // WP-1/R1 (H6): dispatch-time ownership writer — record the
566
682
  // task ⇄ pid ⇄ artifactsDir leg of the ownership map as soon as
@@ -621,7 +737,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
621
737
  // Fire-and-forget async write for steering events (the file was
622
738
  // truncated just above; this incarnation receives only its own
623
739
  // pending steers).
624
- void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
740
+ void appendSteeringAsync(steeringDir, task.id, task.pendingSteers).catch((error) =>
741
+ logInternalError("child-executor.steering-append", error as Error, `taskId=${task.id}`),
742
+ );
625
743
  // RT-8: spread before clearing pendingSteers instead of mutating
626
744
  // in place — preserves the immutable-snapshot invariant (the same
627
745
  // object may already be referenced by the tasks array / snapshots).
@@ -692,7 +810,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
692
810
  const bgLogPath = `${manifest.stateRoot}/background.log`;
693
811
  const eventLine = typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
694
812
  // Fire-and-forget async write for background log
695
- void appendBackgroundLogAsync(bgLogPath, eventLine);
813
+ void appendBackgroundLogAsync(bgLogPath, eventLine).catch((error) =>
814
+ logInternalError("child-executor.background-log", error as Error, `taskId=${task.id}`),
815
+ );
696
816
  }
697
817
  // Always keep in-memory agentProgress fresh (cheap) so the UI/events see
698
818
  // the latest progress, but THROTTLE the disk persist. Previously this
@@ -764,6 +884,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
764
884
  // the persisted events/output after a task always match what the
765
885
  // unbuffered implementation would have written.
766
886
  flushCrewAgentRecordBuffer(manifest, task.id);
887
+ // NEW-4/G25: the attempt is over (success, failure, or throw) — stop
888
+ // the liveness pulse in the same per-attempt cleanup as the wall-clock
889
+ // timeout (R3 contract block): nothing may touch this incarnation's
890
+ // heartbeat anymore, including across the model-fallback respawn gap
891
+ // (where nothing is alive and the heartbeat must be allowed to age).
892
+ heartbeatPulse.stop();
767
893
  if (timeoutHandle) clearTimeout(timeoutHandle);
768
894
  // W2 fix — release the listener so it doesn't leak. {once:true}
769
895
  // only auto-removes when the listener FIRES; if the timeout
@@ -9,25 +9,40 @@
9
9
  * Strategy (in priority order):
10
10
  * 1. Delimiter parse — exact match of `<<<TASK_RESULT:id>>>` markers
11
11
  * 2. Section heading parse — `### Task N of M` / `## Task N` headers
12
+ * (only reachable when the output contains ZERO delimiter markers)
12
13
  * 3. Whole-output broadcast — assign full raw output to ALL taskIds
13
- * (safe fallback for read-only roles where identical context is OK)
14
+ * (safe fallback for read-only roles where identical context is OK;
15
+ * only reachable when the output contains ZERO delimiter markers —
16
+ * see the deadletter note below for why)
17
+ * Deadletter (G20 fail-closed) — a PARTIAL delimiter hit (some but not
18
+ * all taskIds delimited) marks the whole output unpartitionable: no
19
+ * task may receive any partition of it. Previously this case fell
20
+ * through to Strategy 3 and broadcast the full raw output — including
21
+ * other tasks' delimited bodies — to every task in the group. The
22
+ * caller (run-coalesced-task-group) records the group via
23
+ * appendDeadletter and fails the tasks instead.
14
24
  *
15
25
  * Returns an array of { taskId, text } records in input-order. When the
16
26
  * delimiter strategy succeeds, each entry contains that task's delimited
17
27
  * text. When broadcast fallback is used, all entries contain the full
18
- * raw output.
28
+ * raw output. When the deadletter strategy is returned, all entries have
29
+ * empty text — the caller decides what marker to surface per task.
19
30
  *
20
31
  * Note: pure function — no I/O, no LLM calls, deterministic. This is
21
32
  * deliberately the simplest possible splitter; we don't try fuzzy
22
33
  * matching, NL extraction, or retry-the-LLM. The MVP trusts workers
23
- * to follow delimiters 99% of the time and broadcasts the rest.
34
+ * to follow delimiters 99% of the time; a zero-marker miss degrades to
35
+ * broadcast (nothing task-specific exists to leak), and a partial
36
+ * violation now fails closed (G20) instead of leaking cross-task
37
+ * delimited bodies.
24
38
  */
25
39
 
26
40
  export interface SplitResult {
27
41
  taskId: string;
28
42
  text: string;
29
- /** Which strategy produced this entry. */
30
- strategy: "delimiter" | "section" | "broadcast";
43
+ /** Which strategy produced this entry. `deadletter` = G20 fail-closed:
44
+ * partial delimiter hit — output is unpartitionable, text is empty. */
45
+ strategy: "delimiter" | "section" | "broadcast" | "deadletter";
31
46
  }
32
47
 
33
48
  /**
@@ -69,8 +84,30 @@ export function splitCoalescedOutput(rawOutput: string, taskIds: string[]): Spli
69
84
  }));
70
85
  }
71
86
 
87
+ // G20 fail-closed: PARTIAL delimiter hit (0 < hits < taskIds.length).
88
+ // The worker started following the delimiter contract but did not finish
89
+ // it — the output mixes delimited per-task bodies with unattributed
90
+ // prose. No per-task partition can be trusted at this point, not even
91
+ // for the ids that DID get a delimiter: attributing a body to task X is
92
+ // only sound when every other body is provably not X's, and partial
93
+ // coverage cannot prove that. Previously this case skipped Strategy 2
94
+ // entirely (the `delimiterHits.size === 0` guard below) and fell through
95
+ // to Strategy 3, broadcasting the FULL raw output — including other
96
+ // tasks' delimited bodies — to every task in the group (cross-task
97
+ // content leak). Fail closed instead: mark the whole group deadletter;
98
+ // the caller records it and withholds per-task results.
99
+ if (delimiterHits.size > 0) {
100
+ return taskIds.map((id) => ({
101
+ taskId: id,
102
+ text: "",
103
+ strategy: "deadletter" as const,
104
+ }));
105
+ }
106
+
72
107
  // Strategy 2: section heading parse.
73
108
  // Look for `### Task N of M` or `## Task {id}` markers. We try both.
109
+ // Only reachable with ZERO delimiter hits — partial hits return
110
+ // deadletter above.
74
111
  if (delimiterHits.size === 0) {
75
112
  const bySection = parseBySectionHeadings(rawOutput, taskIds);
76
113
  if (bySection.size === taskIds.length) {
@@ -85,6 +122,12 @@ export function splitCoalescedOutput(rawOutput: string, taskIds: string[]): Spli
85
122
  // Strategy 3: broadcast fallback. All tasks get the full raw output.
86
123
  // This is safe for read-only roles (explorers, reviewers) where
87
124
  // downstream consumers care about findings, not strict per-task partitioning.
125
+ // G20: unreachable for partial delimiter hits (those return deadletter
126
+ // above). Broadcast is ONLY reachable when the output contains ZERO
127
+ // delimiter markers and section parsing failed — i.e. the raw output has
128
+ // no delimited per-task bodies at all, so broadcasting it cannot leak one
129
+ // task's delimited content to another task. If that zero-marker premise
130
+ // ever changes, this fallback must be re-audited for cross-task leaks.
88
131
  return taskIds.map((id) => ({
89
132
  taskId: id,
90
133
  text: rawOutput,