pi-crew 0.9.68 → 0.10.1

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 (217) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/agents/analyst.md +1 -1
  3. package/agents/cold-verifier.md +3 -1
  4. package/agents/critic.md +1 -1
  5. package/agents/executor.md +1 -1
  6. package/agents/explorer.md +1 -1
  7. package/agents/planner.md +1 -1
  8. package/agents/reviewer.md +1 -1
  9. package/agents/security-reviewer.md +1 -1
  10. package/agents/test-engineer.md +1 -1
  11. package/agents/verifier.md +1 -1
  12. package/agents/writer.md +1 -1
  13. package/dist/index.mjs +65838 -60331
  14. package/docs/README.md +2 -0
  15. package/docs/actions-reference.md +31 -0
  16. package/docs/commands-reference.md +17 -6
  17. package/docs/resource-formats.md +13 -0
  18. package/package.json +4 -2
  19. package/schema.json +15 -1
  20. package/scripts/resource-sampler.mjs +36 -2
  21. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  22. package/skills/widget-rendering/SKILL.md +7 -7
  23. package/src/agents/agent-config.ts +2 -1
  24. package/src/agents/discover-agents.ts +23 -14
  25. package/src/config/config-merge.ts +183 -0
  26. package/src/config/config-validation.ts +681 -0
  27. package/src/config/config.ts +22 -864
  28. package/src/config/defaults.ts +35 -1
  29. package/src/config/drift-detector.ts +1 -1
  30. package/src/config/env-vars.ts +691 -0
  31. package/src/config/role-tools.ts +8 -8
  32. package/src/config/sanitize-project-config.ts +172 -0
  33. package/src/config/types.ts +30 -0
  34. package/src/extension/async-notifier.ts +25 -2
  35. package/src/extension/crew-vibes/config.ts +2 -1
  36. package/src/extension/plan-orchestrate.ts +132 -0
  37. package/src/extension/registration/commands/dashboard.ts +158 -0
  38. package/src/extension/registration/commands/index.ts +35 -0
  39. package/src/extension/registration/commands/manage.ts +303 -0
  40. package/src/extension/registration/commands/run.ts +214 -0
  41. package/src/extension/registration/commands/shared.ts +631 -0
  42. package/src/extension/registration/commands/status.ts +60 -0
  43. package/src/extension/registration/commands.ts +13 -1224
  44. package/src/extension/registration/lifecycle-handlers.ts +126 -15
  45. package/src/extension/registration/subagent-tools.ts +218 -9
  46. package/src/extension/rpc-hmac.ts +5 -3
  47. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  48. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  49. package/src/extension/team-tool/api/task-claims.ts +109 -40
  50. package/src/extension/team-tool/cancel.ts +84 -50
  51. package/src/extension/team-tool/dispatch/index.ts +1 -0
  52. package/src/extension/team-tool/dispatch/run.ts +4 -1
  53. package/src/extension/team-tool/doctor.ts +103 -1
  54. package/src/extension/team-tool/orchestrate.ts +66 -1
  55. package/src/extension/team-tool/plans.ts +192 -0
  56. package/src/extension/team-tool/respond.ts +197 -65
  57. package/src/extension/team-tool/run-deadline.ts +16 -1
  58. package/src/extension/team-tool/run-intent.ts +63 -0
  59. package/src/extension/team-tool/run.ts +48 -18
  60. package/src/extension/team-tool/status.ts +84 -26
  61. package/src/extension/team-tool.ts +11 -2
  62. package/src/hooks/registry.ts +1 -6
  63. package/src/i18n.ts +9 -0
  64. package/src/prompt/prompt-runtime.ts +521 -2
  65. package/src/prompt/worker-events-channel.ts +173 -0
  66. package/src/runtime/README.md +8 -8
  67. package/src/runtime/async-runner.ts +7 -3
  68. package/src/runtime/background-runner.ts +42 -14
  69. package/src/runtime/broker/broker-issuer.ts +9 -2
  70. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  71. package/src/runtime/broker/crew-broker.ts +838 -10
  72. package/src/runtime/broker/wait-status-cache.ts +157 -0
  73. package/src/runtime/budget-enforcement.ts +281 -0
  74. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  75. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  76. package/src/runtime/child-pi/child-pi.ts +97 -201
  77. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  78. package/src/runtime/crew-agent-records.ts +259 -14
  79. package/src/runtime/delegate-spawn.ts +148 -0
  80. package/src/runtime/deterministic-ast.ts +2 -1
  81. package/src/runtime/dispatch-batch.ts +945 -0
  82. package/src/runtime/finalize-run.ts +557 -0
  83. package/src/runtime/goal-workflow/adaptive-plan.ts +87 -13
  84. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -2
  85. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  86. package/src/runtime/group-join.ts +11 -125
  87. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  88. package/src/runtime/merge-gate.ts +7 -1
  89. package/src/runtime/merge-loop.ts +130 -0
  90. package/src/runtime/model/model-budget-summary.ts +53 -0
  91. package/src/runtime/model/model-fallback.ts +32 -2
  92. package/src/runtime/model/pi-args.ts +10 -0
  93. package/src/runtime/model/provider-extensions.ts +10 -0
  94. package/src/runtime/orphan-worker-registry.ts +1 -1
  95. package/src/runtime/output/output-validator.ts +45 -0
  96. package/src/runtime/parent-guard.ts +3 -1
  97. package/src/runtime/peer-dep.ts +2 -1
  98. package/src/runtime/per-write-validator.ts +0 -5
  99. package/src/runtime/pi-spawn.ts +61 -15
  100. package/src/runtime/plan-approval.ts +125 -0
  101. package/src/runtime/plan-replan.ts +151 -0
  102. package/src/runtime/recovery/checkpoint.ts +0 -18
  103. package/src/runtime/recovery/crash-recovery.ts +95 -46
  104. package/src/runtime/scheduler-context.ts +98 -0
  105. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  106. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  107. package/src/runtime/scheduling/nested-slots.ts +70 -0
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  109. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  110. package/src/runtime/settings-store.ts +219 -0
  111. package/src/runtime/spawn-policy.ts +217 -0
  112. package/src/runtime/stale-reconciler.ts +87 -6
  113. package/src/runtime/subagent-manager.ts +25 -1
  114. package/src/runtime/task-output-context.ts +230 -9
  115. package/src/runtime/task-packet.ts +23 -1
  116. package/src/runtime/task-runner/child-executor.ts +106 -7
  117. package/src/runtime/task-runner/post-execution.ts +125 -1
  118. package/src/runtime/task-runner/pre-execution.ts +39 -1
  119. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  120. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  121. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  122. package/src/runtime/task-runner/state-helpers.ts +26 -24
  123. package/src/runtime/task-runner.ts +11 -0
  124. package/src/runtime/team-runner.ts +129 -1671
  125. package/src/runtime/verification/spec-sandbox.ts +255 -0
  126. package/src/runtime/verification/verification-gates.ts +3 -2
  127. package/src/runtime/verification/verification-worktree.ts +2 -1
  128. package/src/runtime/workflow-phase-advance.ts +100 -0
  129. package/src/runtime/workspace-tree.ts +9 -0
  130. package/src/schema/config-schema.ts +65 -25
  131. package/src/schema/sensitive-config-paths.ts +64 -0
  132. package/src/schema/team-tool-schema.ts +13 -3
  133. package/src/state/README.md +4 -10
  134. package/src/state/atomic-write.ts +20 -3
  135. package/src/state/contracts.ts +38 -0
  136. package/src/state/coordination/mailbox.ts +12 -2
  137. package/src/state/event-log/cursor.ts +223 -0
  138. package/src/state/event-log/event-log-rotation.ts +12 -4
  139. package/src/state/event-log/event-log.ts +152 -369
  140. package/src/state/event-log/sequence-cache.ts +373 -0
  141. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  142. package/src/state/stores/active-run-registry.ts +3 -2
  143. package/src/state/stores/manifest-io.ts +237 -0
  144. package/src/state/stores/ownership-map.ts +162 -0
  145. package/src/state/stores/plan-store.ts +241 -0
  146. package/src/state/stores/run-cache.ts +0 -90
  147. package/src/state/stores/spec-store.ts +189 -0
  148. package/src/state/stores/state-store.ts +66 -229
  149. package/src/state/types.ts +197 -0
  150. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  151. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  152. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  153. package/src/ui/heartbeat-aggregator.ts +9 -1
  154. package/src/ui/keybinding-map.ts +54 -13
  155. package/src/ui/powerbar-publisher.ts +52 -1
  156. package/src/ui/run-dashboard.ts +31 -5
  157. package/src/ui/run-snapshot-cache.ts +57 -30
  158. package/src/ui/snapshot-types.ts +6 -1
  159. package/src/ui/widget/widget-renderer.ts +9 -1
  160. package/src/utils/file-coalescer.ts +0 -4
  161. package/src/utils/fs-errno.ts +66 -0
  162. package/src/utils/fs-watch.ts +1 -1
  163. package/src/utils/internal-error.ts +3 -1
  164. package/src/utils/paths.ts +11 -3
  165. package/src/utils/task-name-generator.ts +1 -8
  166. package/src/workflows/discover-workflows.ts +19 -2
  167. package/src/workflows/validate-workflow.ts +7 -1
  168. package/src/workflows/workflow-config.ts +10 -0
  169. package/src/workflows/workflow-serializer.ts +3 -0
  170. package/src/worktree/worktree-manager.ts +22 -0
  171. package/src/agents/agent-search.ts +0 -98
  172. package/src/benchmark/benchmark-runner.ts +0 -313
  173. package/src/benchmark/feedback-loop.ts +0 -73
  174. package/src/config/resilient-parser.ts +0 -117
  175. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  176. package/src/extension/result-watcher.ts +0 -139
  177. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  178. package/src/observability/metric-retention.ts +0 -64
  179. package/src/runtime/compaction/compaction-summary.ts +0 -278
  180. package/src/runtime/errors/crew-errors.ts +0 -162
  181. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  182. package/src/runtime/loop-gates.ts +0 -128
  183. package/src/runtime/metric-parser.ts +0 -36
  184. package/src/runtime/output/stream-preview.ts +0 -184
  185. package/src/runtime/output/tool-progress.ts +0 -278
  186. package/src/runtime/phase-tracker.ts +0 -385
  187. package/src/runtime/pipeline-runner.ts +0 -523
  188. package/src/runtime/process/process-lifecycle.ts +0 -491
  189. package/src/runtime/recovery/retry-runner.ts +0 -330
  190. package/src/runtime/run-drift.ts +0 -219
  191. package/src/runtime/task-quality.ts +0 -199
  192. package/src/runtime/task-runner/run-projection.ts +0 -128
  193. package/src/runtime/verification/post-checks.ts +0 -142
  194. package/src/state/coordination/schedule.ts +0 -166
  195. package/src/state/event-log/jsonl-writer.ts +0 -115
  196. package/src/state/hook-instinct-bridge.ts +0 -94
  197. package/src/state/hook-integrations.ts +0 -51
  198. package/src/state/session-state-map.ts +0 -51
  199. package/src/state/stores/blob-store.ts +0 -308
  200. package/src/state/stores/instinct-store.ts +0 -275
  201. package/src/state/stores/observation-store.ts +0 -176
  202. package/src/state/tiered-eval.ts +0 -480
  203. package/src/state/types-eval.ts +0 -58
  204. package/src/tools/safe-bash-extension.ts +0 -54
  205. package/src/tools/safe-bash.ts +0 -505
  206. package/src/ui/agent-management-overlay.ts +0 -160
  207. package/src/ui/crew-footer.ts +0 -102
  208. package/src/ui/crew-select-list.ts +0 -114
  209. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  210. package/src/ui/transcript-entries.ts +0 -256
  211. package/src/utils/conflict-detect.ts +0 -721
  212. package/src/utils/fingerprint.ts +0 -180
  213. package/src/utils/gh-protocol.ts +0 -556
  214. package/src/utils/project-detector.ts +0 -160
  215. package/src/utils/sse-parser.ts +0 -131
  216. package/src/workflows/cost-estimator.ts +0 -34
  217. package/src/workflows/intermediate-store.ts +0 -166
@@ -14,11 +14,15 @@
14
14
  //
15
15
  // `__test__parseAdaptivePlan` and `__test__repairAdaptivePlan` are re-exported
16
16
  // from team-runner.ts so existing test imports keep working.
17
+
18
+ import { randomUUID } from "node:crypto";
17
19
  import * as fs from "node:fs";
20
+ import { getCrewEnv } from "../../config/env-vars.ts";
18
21
  import { appendEventAsync, appendEventFireAndForget } from "../../state/event-log/event-log.ts";
19
22
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
20
- import { saveRunManifestAsync } from "../../state/stores/state-store.ts";
21
- import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
23
+ import { appendPlanRevision, getCurrentPlanRecord } from "../../state/stores/plan-store.ts";
24
+ import { saveRunManifestAsync, saveRunTasksAsync } from "../../state/stores/state-store.ts";
25
+ import type { PlanRecord, TeamRunManifest, TeamTaskState } from "../../state/types.ts";
22
26
  import type { TeamConfig } from "../../teams/team-config.ts";
23
27
  import type { WorkflowConfig, WorkflowStep } from "../../workflows/workflow-config.ts";
24
28
  import { refreshTaskGraphQueues } from "../scheduling/task-graph-scheduler.ts";
@@ -39,7 +43,15 @@ export interface AdaptivePlan {
39
43
  phases: AdaptivePlanPhase[];
40
44
  }
41
45
 
42
- const MAX_ADAPTIVE_TASKS = 12;
46
+ /**
47
+ * T2/R4 (ADR-4 §5): the cap is PER-PHASE, not global. Pre-v2 this was a
48
+ * global flatten cap (MAX_ADAPTIVE_TASKS = 12) applied across the whole plan —
49
+ * a 3-phase plan may now schedule up to 3 x this across its life, bounded and
50
+ * visible per phase in the persisted PlanRecord. Deliberately a module
51
+ * constant, NOT a config key (the `adaptive` config section belongs to
52
+ * T3/WP-5 — review finding F3 of the ADR round).
53
+ */
54
+ const ADAPTIVE_MAX_TASKS_PER_PHASE = 12;
43
55
 
44
56
  export function slug(value: string): string {
45
57
  return (
@@ -124,7 +136,6 @@ export function parseAdaptivePlan(text: string, allowedRoles: string[]): Adaptiv
124
136
  if (!phasesRaw) return undefined;
125
137
  const allowed = new Set(allowedRoles);
126
138
  const phases: AdaptivePlanPhase[] = [];
127
- let total = 0;
128
139
  for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
129
140
  if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) return undefined;
130
141
  const phaseObj = phaseRaw as { name?: unknown; tasks?: unknown };
@@ -139,13 +150,12 @@ export function parseAdaptivePlan(text: string, allowedRoles: string[]): Adaptiv
139
150
  };
140
151
  if (typeof taskObj.role !== "string" || !allowed.has(taskObj.role)) return undefined;
141
152
  if (typeof taskObj.task !== "string" || !taskObj.task.trim()) return undefined;
142
- if (total >= MAX_ADAPTIVE_TASKS) return undefined;
153
+ if (tasks.length >= ADAPTIVE_MAX_TASKS_PER_PHASE) return undefined; // per-phase (ADR-4 §5)
143
154
  tasks.push({
144
155
  role: taskObj.role,
145
156
  title: typeof taskObj.title === "string" ? taskObj.title : undefined,
146
157
  task: taskObj.task.trim(),
147
158
  });
148
- total++;
149
159
  }
150
160
  phases.push({
151
161
  name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`,
@@ -289,7 +299,6 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
289
299
  if (!phasesRaw) return { repaired: false, reason: "missing-phases" };
290
300
  const allowed = new Set(allowedRoles);
291
301
  const phases: AdaptivePlanPhase[] = [];
292
- let total = 0;
293
302
  let repaired = salvageUsed || raw !== closeResult.text;
294
303
  for (const [phaseIndex, phaseRaw] of phasesRaw.entries()) {
295
304
  if (!phaseRaw || typeof phaseRaw !== "object" || Array.isArray(phaseRaw)) continue;
@@ -297,7 +306,8 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
297
306
  if (!Array.isArray(phaseObj.tasks)) continue;
298
307
  const tasks: AdaptivePlanTask[] = [];
299
308
  for (const taskRaw of phaseObj.tasks) {
300
- if (total >= MAX_ADAPTIVE_TASKS) {
309
+ if (tasks.length >= ADAPTIVE_MAX_TASKS_PER_PHASE) {
310
+ // Per-phase cap (ADR-4 §5): truncate THIS phase, keep later phases.
301
311
  repaired = true;
302
312
  break;
303
313
  }
@@ -321,14 +331,12 @@ export function repairAdaptivePlan(text: string, allowedRoles: string[]): { plan
321
331
  title: typeof taskObj.title === "string" ? taskObj.title : undefined,
322
332
  task: taskText,
323
333
  });
324
- total++;
325
334
  }
326
335
  if (tasks.length)
327
336
  phases.push({
328
337
  name: typeof phaseObj.name === "string" && phaseObj.name.trim() ? phaseObj.name.trim() : `phase-${phaseIndex + 1}`,
329
338
  tasks,
330
339
  });
331
- if (total >= MAX_ADAPTIVE_TASKS) break;
332
340
  }
333
341
  return phases.length
334
342
  ? {
@@ -409,6 +417,9 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
409
417
  };
410
418
  }
411
419
  const assessTask = completedAssess;
420
+ // Review R5: manifest variant tracked through the repair path — the plan
421
+ // save below must not clobber the repair artifact descriptor.
422
+ let manifestBase = input.manifest;
412
423
  const resultPath = completedAssess.resultArtifact.path;
413
424
  let text = "";
414
425
  try {
@@ -431,7 +442,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
431
442
  let plan = parseAdaptivePlan(text, allowedRoles);
432
443
  if (!plan) {
433
444
  const repair =
434
- process.env.PI_CREW_ADAPTIVE_REPAIR === "0" || process.env.PI_TEAMS_ADAPTIVE_REPAIR === "0"
445
+ getCrewEnv("PI_CREW_ADAPTIVE_REPAIR") === "0" || getCrewEnv("PI_TEAMS_ADAPTIVE_REPAIR") === "0"
435
446
  ? { repaired: false, reason: "disabled" }
436
447
  : repairAdaptivePlan(text, allowedRoles);
437
448
  if (repair.plan) {
@@ -442,11 +453,12 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
442
453
  producer: assessTask.id,
443
454
  content: `${JSON.stringify({ reason: repair.reason, phases: repair.plan.phases.map((phase) => ({ name: phase.name, count: phase.tasks.length, roles: phase.tasks.map((task) => task.role) })) }, null, 2)}\n`,
444
455
  });
445
- await saveRunManifestAsync({
456
+ manifestBase = {
446
457
  ...input.manifest,
447
458
  updatedAt: new Date().toISOString(),
448
459
  artifacts: [...input.manifest.artifacts, repairArtifact],
449
- });
460
+ };
461
+ await saveRunManifestAsync(manifestBase);
450
462
  appendEventFireAndForget(input.manifest.eventsPath, {
451
463
  type: "adaptive.plan_repaired",
452
464
  runId: input.manifest.runId,
@@ -478,14 +490,30 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
478
490
  }
479
491
  const steps: WorkflowStep[] = [];
480
492
  const tasks: TeamTaskState[] = [];
493
+ // T2/R4 (ADR-4 §6 producer 2): item ids are stable across revisions —
494
+ // phase/task POSITION based, independent of role renames.
495
+ const planPhases: PlanRecord["phases"] = [];
496
+ const planItems: PlanRecord["items"] = [];
481
497
  let previousStepIds = ["assess"];
482
498
  let counter = 0;
483
499
  for (const [phaseIndex, phase] of plan.phases.entries()) {
484
500
  const currentStepIds: string[] = [];
501
+ const phaseItemIds: string[] = [];
485
502
  for (const [taskIndex, planned] of phase.tasks.entries()) {
486
503
  counter++;
504
+ const itemId = `adaptive-p${phaseIndex + 1}-t${taskIndex + 1}`;
487
505
  const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
488
506
  const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
507
+ phaseItemIds.push(itemId);
508
+ planItems.push({
509
+ id: itemId,
510
+ ref: stepId,
511
+ title: planned.title ?? planned.task.slice(0, 80),
512
+ taskIds: [],
513
+ specIds: [],
514
+ acceptance: [],
515
+ status: "pending",
516
+ });
489
517
  steps.push({
490
518
  id: stepId,
491
519
  role: planned.role,
@@ -504,6 +532,7 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
504
532
  dependsOn: previousStepIds,
505
533
  cwd: input.manifest.cwd,
506
534
  adaptive: { phase: phase.name, task: planned.task },
535
+ planItem: itemId,
507
536
  graph: {
508
537
  taskId,
509
538
  dependencies: previousStepIds,
@@ -513,6 +542,12 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
513
542
  });
514
543
  currentStepIds.push(stepId);
515
544
  }
545
+ planPhases.push({
546
+ id: `adaptive-phase-${phaseIndex + 1}`,
547
+ title: phase.name,
548
+ itemIds: phaseItemIds,
549
+ status: "pending",
550
+ });
516
551
  previousStepIds = currentStepIds;
517
552
  }
518
553
  const dependencyTaskIdByStep = new Map<string, string>([
@@ -531,6 +566,45 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
531
566
  : task.graph,
532
567
  }));
533
568
  const allTasks = refreshTaskGraphQueues([...input.tasks, ...withGraph]);
569
+ // T2/R4 (ADR-4 §2/§6): persist the PlanRecord + manifest pointer (dual-write;
570
+ // crash between the two is benign — getCurrentPlanRecord falls back to the
571
+ // highest version). Producers never set taskIds (single-writer rule §3).
572
+ const planRecord: PlanRecord = {
573
+ id: randomUUID(),
574
+ runId: input.manifest.runId,
575
+ version: 1,
576
+ title: `Adaptive plan (${plan.phases.length} phase(s))`,
577
+ phases: planPhases,
578
+ items: planItems,
579
+ createdAt: new Date().toISOString(),
580
+ authorTaskId: assessTask.id,
581
+ };
582
+ // Review R1 (P1, crash-window): if a record ALREADY exists (the append
583
+ // survived a kill between the plans.json write and the tasks save below),
584
+ // REUSE it instead of re-appending — the fresh randomUUID would throw
585
+ // lineage-break on resume and permanently brick the run. Task/item ids are
586
+ // position-deterministic from the SAME parsed artifact, so the rebuilt
587
+ // linkage matches the surviving record exactly.
588
+ const existingRecord = getCurrentPlanRecord(input.manifest);
589
+ if (!existingRecord) appendPlanRevision(input.manifest, planRecord);
590
+ // Review round-2 N1: on the crash-window reuse path the pointer MUST name
591
+ // the SURVIVING record — the fresh planRecord was never persisted, so its
592
+ // randomUUID would dangle (masked today by the highest-version fallback,
593
+ // but it violates ADR-4 §2's pointer invariant).
594
+ const planPointer = existingRecord
595
+ ? { id: existingRecord.id, version: existingRecord.version }
596
+ : { id: planRecord.id, version: planRecord.version };
597
+ await saveRunManifestAsync({
598
+ ...manifestBase,
599
+ updatedAt: new Date().toISOString(),
600
+ plan: planPointer,
601
+ });
602
+ // FIX (T2/B2 case e, pre-existing): the injected tasks MUST be persisted at
603
+ // injection time. mergeUnitResult reloads tasks.json as the merge base and
604
+ // mergeTaskUpdatesPreservingTerminal DROPS updates for ids absent from the
605
+ // base — an injection that only lived in memory had its completed adaptive
606
+ // tasks silently erased from disk at the first merge.
607
+ await saveRunTasksAsync(manifestBase, allTasks);
534
608
  await appendEventAsync(input.manifest.eventsPath, {
535
609
  type: "adaptive.plan_injected",
536
610
  runId: input.manifest.runId,
@@ -20,6 +20,7 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { transformSync } from "esbuild";
23
+ import { getCrewEnv } from "../../config/env-vars.ts";
23
24
  import { appendEvent } from "../../state/event-log/event-log.ts";
24
25
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
25
26
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
@@ -151,7 +152,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
151
152
  // require, fs, child_process). Cloning a hostile repo and running its workflow = arbitrary
152
153
  // code execution. Default-deny project workflows unless the user explicitly opts in via
153
154
  // PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
154
- if (workflow.source === "project" && process.env.PI_CREW_TRUST_PROJECT_DWF !== "1") {
155
+ if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
155
156
  appendEvent(eventsPath, {
156
157
  type: "dwf.trust_denied",
157
158
  runId: manifest.runId,
@@ -223,7 +224,7 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
223
224
  // it does NOT kill the script. Promise.race with a hard timeout at least returns an
224
225
  // error so the runner doesn't hang. The spawned child process is leaked, but the
225
226
  // dynamic-workflow returns failure promptly. (v1.5: use Worker threads to actually kill.)
226
- const SCRIPT_TIMEOUT_MS = Number.parseInt(process.env.PI_CREW_DWF_SCRIPT_TIMEOUT_MS ?? "", 10) || 1_800_000; // 30 min default (was 10 min; raised for distill-dwf + similar long pipelines)
227
+ const SCRIPT_TIMEOUT_MS = Number.parseInt(getCrewEnv("PI_CREW_DWF_SCRIPT_TIMEOUT_MS") ?? "", 10) || 1_800_000; // 30 min default (was 10 min; raised for distill-dwf + similar long pipelines)
227
228
  let timeoutHandle: NodeJS.Timeout | undefined;
228
229
  const timeoutPromise = new Promise<never>((_, reject) => {
229
230
  timeoutHandle = setTimeout(() => {
@@ -6,7 +6,7 @@
6
6
  * goal loop owns OUTER state) + §0c C10 (hardening: assertSafePathId + UUID goalId).
7
7
  *
8
8
  * Stores GoalLoopState as atomic JSON at <crewRoot>/state/goals/<goalId>.json.
9
- * Modeled on ScheduleStore (state/coordination/schedule.ts:86) but with atomicWriteJson +
9
+ * Modeled on the (removed in Phase 1.7) ScheduleStore coordination pattern but with atomicWriteJson +
10
10
  * path-traversal defense (assertSafePathId on every public method).
11
11
  *
12
12
  * Per §0c C2: budget lives here (budgetUsed accumulates collectRunMetrics across turns);
@@ -3,7 +3,7 @@ import { appendMailboxMessage, findMailboxMessageByRequestId, readDeliveryState
3
3
  import { appendEvent } from "../state/event-log/event-log.ts";
4
4
  import { writeArtifact } from "../state/stores/artifact-store.ts";
5
5
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
- import { aggregateTaskOutputs } from "./task-output-context.ts";
6
+ import { aggregateTaskOutputs, type ResultArtifactReadCache } from "./task-output-context.ts";
7
7
 
8
8
  export type CrewGroupJoinMode = "off" | "group" | "smart";
9
9
 
@@ -51,6 +51,11 @@ export function deliverGroupJoin(input: {
51
51
  batch: TeamTaskState[];
52
52
  allTasks: TeamTaskState[];
53
53
  partial?: boolean;
54
+ /** R10-1: optional per-run result-artifact read cache. The closeout
55
+ * aggregates the same settled batch for the batch-summary artifact
56
+ * immediately before this call — passing that cache here turns the
57
+ * group-join aggregation into cache hits (byte-identical output). */
58
+ cache?: ResultArtifactReadCache;
54
59
  }): CrewGroupJoinDelivery | undefined {
55
60
  if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
56
61
  const taskIds = input.batch.map((task) => task.id);
@@ -63,7 +68,7 @@ export function deliverGroupJoin(input: {
63
68
  const remaining = latest.filter((task) => task.status === "queued" || task.status === "running").map((task) => task.id);
64
69
  const partial = input.partial ?? remaining.length > 0;
65
70
  const batchId = batchIdFor(input.manifest.runId, taskIds);
66
- const summary = aggregateTaskOutputs(latest, input.manifest);
71
+ const summary = aggregateTaskOutputs(latest, input.manifest, input.cache);
67
72
  const requestId = requestIdFor(input.manifest.runId, batchId, partial);
68
73
  const existingMailbox = findMailboxMessageByRequestId(input.manifest, requestId);
69
74
  const existingStatus = existingMailbox
@@ -141,127 +146,8 @@ export function deliverGroupJoin(input: {
141
146
  return { ...delivery, artifact, messageId: mailbox.id };
142
147
  }
143
148
 
144
- import type { CrewAgentRecord } from "./crew-agent-runtime.ts";
145
-
149
+ /**
150
+ * Persisted settings vocabulary for join mode (imported by settings-store.ts
151
+ * for `defaultJoinMode` validation and config application).
152
+ */
146
153
  export type JoinMode = "async" | "group" | "smart";
147
- export type DeliveryCallback = (records: CrewAgentRecord[], partial: boolean) => void;
148
-
149
- interface AgentGroup {
150
- groupId: string;
151
- agentIds: Set<string>;
152
- completedRecords: Map<string, CrewAgentRecord>;
153
- timeoutHandle?: ReturnType<typeof setTimeout>;
154
- delivered: boolean;
155
- isStraggler: boolean;
156
- }
157
-
158
- const DEFAULT_TIMEOUT = 30_000;
159
- const STRAGGLER_TIMEOUT = 15_000;
160
-
161
- export class GroupJoinManager {
162
- private groups = new Map<string, AgentGroup>();
163
- private agentToGroup = new Map<string, string>();
164
-
165
- private deliverCb: DeliveryCallback;
166
- private groupTimeout: number;
167
-
168
- constructor(deliverCb: DeliveryCallback, groupTimeout = DEFAULT_TIMEOUT) {
169
- this.deliverCb = deliverCb;
170
- this.groupTimeout = groupTimeout;
171
- }
172
-
173
- registerGroup(groupId: string, agentIds: string[]): void {
174
- const group: AgentGroup = {
175
- groupId,
176
- agentIds: new Set(agentIds),
177
- completedRecords: new Map(),
178
- delivered: false,
179
- isStraggler: false,
180
- };
181
- this.groups.set(groupId, group);
182
- for (const id of agentIds) {
183
- this.agentToGroup.set(id, groupId);
184
- }
185
- }
186
-
187
- onAgentComplete(record: CrewAgentRecord): "delivered" | "held" | "pass" {
188
- const groupId = this.agentToGroup.get(record.taskId);
189
- if (!groupId) return "pass";
190
-
191
- const group = this.groups.get(groupId);
192
- if (!group || group.delivered) return "pass";
193
-
194
- group.completedRecords.set(record.taskId, record);
195
-
196
- if (group.completedRecords.size >= group.agentIds.size) {
197
- this.deliver(group, false);
198
- return "delivered";
199
- }
200
-
201
- if (!group.timeoutHandle) {
202
- const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
203
- group.timeoutHandle = setTimeout(() => {
204
- this.onTimeout(group);
205
- }, timeout);
206
- }
207
-
208
- return "held";
209
- }
210
-
211
- private onTimeout(group: AgentGroup): void {
212
- if (group.delivered) return;
213
- group.timeoutHandle = undefined;
214
-
215
- const remaining = new Set<string>();
216
- for (const id of group.agentIds) {
217
- if (!group.completedRecords.has(id)) remaining.add(id);
218
- }
219
-
220
- for (const id of group.completedRecords.keys()) {
221
- this.agentToGroup.delete(id);
222
- }
223
-
224
- this.deliverCb([...group.completedRecords.values()], true);
225
-
226
- group.completedRecords.clear();
227
- group.agentIds = remaining;
228
- group.isStraggler = true;
229
-
230
- // Re-arm timer for remaining stragglers so they aren't silently abandoned
231
- if (remaining.size > 0) {
232
- group.timeoutHandle = setTimeout(() => this.onTimeout(group), STRAGGLER_TIMEOUT);
233
- group.timeoutHandle.unref();
234
- }
235
- }
236
-
237
- private deliver(group: AgentGroup, partial: boolean): void {
238
- if (group.timeoutHandle) {
239
- clearTimeout(group.timeoutHandle);
240
- group.timeoutHandle = undefined;
241
- }
242
- group.delivered = true;
243
- this.deliverCb([...group.completedRecords.values()], partial);
244
- this.cleanupGroup(group.groupId);
245
- }
246
-
247
- private cleanupGroup(groupId: string): void {
248
- const group = this.groups.get(groupId);
249
- if (!group) return;
250
- for (const id of group.agentIds) {
251
- this.agentToGroup.delete(id);
252
- }
253
- this.groups.delete(groupId);
254
- }
255
-
256
- isGrouped(agentId: string): boolean {
257
- return this.agentToGroup.has(agentId);
258
- }
259
-
260
- dispose(): void {
261
- for (const group of this.groups.values()) {
262
- if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
263
- }
264
- this.groups.clear();
265
- this.agentToGroup.clear();
266
- }
267
- }
@@ -5,6 +5,7 @@ import { resolveToolPolicy } from "../../agents/agent-config.ts";
5
5
  import type { CrewRuntimeConfig } from "../../config/config.ts";
6
6
  import { loadConfig } from "../../config/config.ts";
7
7
  import { DEFAULT_LIVE_SESSION } from "../../config/defaults.ts";
8
+ import { getCrewEnv } from "../../config/env-vars.ts";
8
9
  import { appendEvent, appendEventFireAndForget } from "../../state/event-log/event-log.ts";
9
10
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../../state/types.ts";
10
11
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -81,6 +82,13 @@ import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-s
81
82
  * explorer subagents launched together; 3 of 4 crashed.)
82
83
  */
83
84
  let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
85
+
86
+ /**
87
+ * Phase 4 (ADR 2026-08-15-runtime-convergence, decision (a) — freeze):
88
+ * warn-once flag so the experimental-path notice fires at most once per
89
+ * process, not per task.
90
+ */
91
+ let experimentalWarned = false;
84
92
  function loadLiveSessionModule(): Promise<LiveSessionModule> {
85
93
  if (!liveSessionModulePromise) {
86
94
  liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
@@ -549,13 +557,30 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
549
557
  // any module. Under tsx, concurrent first-imports race module-record
550
558
  // instantiation; awaiting the registration-time warmup eliminates the window.
551
559
  await awaitRuntimeWarmup();
560
+
561
+ // Phase 4 (ADR 2026-08-15-runtime-convergence, decision (a) — freeze):
562
+ // the live-session path is permanently EXPERIMENTAL and intentionally
563
+ // diverges from the supported child-process path (no worker-cap semaphore,
564
+ // no depth guard, permissive SDK tool surface, single-model-fallback
565
+ // report). Warn once per process so operators see the notice without
566
+ // per-task spam.
567
+ if (!experimentalWarned) {
568
+ experimentalWarned = true;
569
+ logInternalError(
570
+ "live-session.experimental",
571
+ new Error("experimental path"),
572
+ "runtime.mode=live-session is EXPERIMENTAL and frozen (decision (a)); it diverges from child-process semantics (no worker-cap/depth guard, permissive tools) — see docs/decisions/2026-08-15-runtime-convergence.md",
573
+ "warn",
574
+ );
575
+ }
576
+
552
577
  const isCurrent = input.isCurrent ?? (() => true);
553
578
  let streamOut: StreamingOutputHandle | undefined;
554
579
 
555
580
  // G1: Capture yield result from custom tool callback
556
581
  let customToolYieldResult: YieldResult | undefined;
557
582
  let customToolYieldResolved = false;
558
- if (process.env.PI_CREW_MOCK_LIVE_SESSION === "success") {
583
+ if (getCrewEnv("PI_CREW_MOCK_LIVE_SESSION") === "success") {
559
584
  const agentId = `${input.manifest.runId}:${input.task.id}`;
560
585
  const inherited =
561
586
  input.runtimeConfig?.inheritContext === true && input.parentContext ? ` with inherited context: ${input.parentContext}` : "";
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { TEAM_TASK_STATUSES, TEAM_TERMINAL_TASK_STATUSES, type TeamTaskStatus } from "../state/contracts.ts";
15
15
  import type { TeamTaskState } from "../state/types.ts";
16
+ import { logInternalError } from "../utils/internal-error.ts";
16
17
  import { refreshTaskGraphQueues } from "./scheduling/task-graph-scheduler.ts";
17
18
 
18
19
  export function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
@@ -123,7 +124,12 @@ export function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskS
123
124
  // Malformed finishedAt (NaN) is treated as Infinity — invalid state should be
124
125
  // replaced rather than persisting corruption. Log warning for visibility.
125
126
  if (!Number.isFinite(currentTime)) {
126
- console.warn(`[merge-gate] Task ${current.id} has malformed finishedAt: ${current.finishedAt}`);
127
+ logInternalError(
128
+ "merge-gate",
129
+ new Error(`Task ${current.id} has malformed finishedAt: ${current.finishedAt}`),
130
+ undefined,
131
+ "warn",
132
+ );
127
133
  }
128
134
  if (isMalformedFinishedAtReplacement(currentTime, updatedTime)) {
129
135
  return true;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Merge of settled dispatch units into the run state for the team-run
3
+ * scheduler loop.
4
+ *
5
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
6
+ * CORE-4 extraction 5). Pure code motion: isRunTerminalPreserved +
7
+ * mergeUnitResult moved verbatim, including the RT-12 / NEW-D1 / R15-2 /
8
+ * CANCEL-1 comments.
9
+ *
10
+ * `isRunTerminalPreserved` lives here (not in finalize-run.ts) because it is
11
+ * shared by mergeUnitResult, finalizeRun (finalize-run.ts imports it from
12
+ * here) and the core loop in team-runner.ts (which imports it from here) —
13
+ * keeping it here avoids a merge-loop ↔ finalize-run import cycle.
14
+ */
15
+ import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
16
+ import { withRunLock } from "../state/coordination/locks.ts";
17
+ import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
18
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
+ import { classifyFatalFsError } from "../utils/fs-errno.ts";
20
+ import { cancelNonTerminalTasks } from "./dispatch-batch.ts";
21
+ import { mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
22
+ import type { SchedulerContext, SchedulerDecision } from "./scheduler-context.ts";
23
+ import { mergeArtifacts } from "./team-runner-artifacts.ts";
24
+
25
+ /**
26
+ * CORE-4 extraction 5: wait for one in-flight dispatch unit to settle and
27
+ * merge its result into the run state.
28
+ *
29
+ * Awaits Promise.race on ctx.pendingUnits; the first settled unit is merged
30
+ * into ctx.manifest/tasks under the run lock (flushPendingAtomicWrites +
31
+ * loadRunManifestById + mergeTaskUpdatesPreservingTerminal + save). The settled
32
+ * unit is then deleted from ctx.pendingUnits, and the merge outcome (taskIds
33
+ * + result object) is recorded on ctx.settledMerge for the post-merge inline
34
+ * logic (cancel-during-exec check + batch summary).
35
+ *
36
+ * Returns null to continue to the phase/budget check. A `{ kind: "return" }`
37
+ * decision is reserved for future run-complete/failure detection during merge.
38
+ *
39
+ * Reads ctx.pendingUnits/manifest/tasks. Mutates ctx.pendingUnits (delete),
40
+ * ctx.manifest/tasks, ctx.settledMerge.
41
+ *
42
+ * @param ctx The scheduler context.
43
+ */
44
+ /** R15-2/R15-1: run statuses that must never be overwritten by an in-memory
45
+ * derived status (mergeUnitResult force-"running", finalizeRun "completed").
46
+ * "blocked" is deliberately EXCLUDED — a blocked run can be unblocked and the
47
+ * finalize chain may legitimately derive blocked from in-memory state. */
48
+ export function isRunTerminalPreserved(status: TeamRunManifest["status"]): boolean {
49
+ return status === "cancelled" || status === "failed" || status === "completed";
50
+ }
51
+
52
+ export async function mergeUnitResult(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
53
+ // RT-12: race on pre-created wrapper promises (created once at dispatch
54
+ // time) instead of rebuilding a wrapper-promise array with new async
55
+ // closures every iteration. This reduces allocation from O(C×T) wrapper
56
+ // promises to O(C) total (one per unit, created once at dispatch).
57
+ const settled = await Promise.race([...ctx.pendingUnits.values()].map((u) => u.wrapped));
58
+ const completedUnit = ctx.pendingUnits.get(settled.unitKey)!;
59
+ ctx.pendingUnits.delete(settled.unitKey);
60
+
61
+ // Build the single result to merge. On rejection, synthesize a failed
62
+ // result so the run continues (mirrors the old validResults guard).
63
+ // bug-026 sub-issue B: a rejected unit means runTeamTask itself threw —
64
+ // typically an atomicWriteFile/persistSingleTaskUpdate ENOSPC mid-run.
65
+ // Classify the errno and stamp failureCause on the synthesized failed
66
+ // tasks so the operator sees "failed (disk full)", not a generic write
67
+ // error string.
68
+ const thrownFailureCause = settled.result ? undefined : classifyFatalFsError(settled.error);
69
+ const resultToMerge: { manifest: TeamRunManifest; tasks: TeamTaskState[] } = settled.result ?? {
70
+ manifest: ctx.manifest,
71
+ tasks: cancelNonTerminalTasks(ctx.tasks, "failed", settled.error!.message, (t) => completedUnit.taskIds.includes(t.id)).map((t) =>
72
+ thrownFailureCause && t.status === "failed" ? { ...t, failureCause: thrownFailureCause } : t,
73
+ ),
74
+ };
75
+ const validResults = [resultToMerge];
76
+ // Reconstruct manifest from the last worker's snapshot. The .artifacts field
77
+ // is re-merged from both the team-runner's in-memory state and all workers'
78
+ // snapshots, so artifact writes by task-runner (which individually save manifest
79
+ // after writing artifacts) are safely persisted. The in-memory manifest is only
80
+ // used for the next batch iteration's orchestration — actual persistence is safe.
81
+ // Use updateRunStatus to recompute manifest status from merged tasks rather than
82
+ // relying on the last result's manifest (which is arbitrary due to mapConcurrent
83
+ // returning results in arbitrary order).
84
+ // Use the in-memory manifest as base (not the last-completing worker's snapshot).
85
+ // Recompute status from merged tasks so the manifest reflects actual task state,
86
+ // not the arbitrary order in which mapConcurrent returned results.
87
+ // Read committed manifest from disk inside the lock so artifact merge is based
88
+ // on committed state, not in-memory state that may differ from disk.
89
+ const mergeResult = await withRunLock(ctx.manifest, async () => {
90
+ // NEW-D1: flush any pending coalesced atomic writes before reading from
91
+ // disk. Without this, a worker's async manifest save (coalesced by
92
+ // atomic-write) may not be committed yet, causing a lost-update on the
93
+ // merge read. flushPendingAtomicWrites forces all queued writes to disk.
94
+ flushPendingAtomicWrites();
95
+ const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
96
+ const diskManifest = disk?.manifest ?? ctx.manifest;
97
+ const diskArtifacts = diskManifest.artifacts;
98
+ const reconciledArtifacts = mergeArtifacts([...diskArtifacts, ...validResults.map((item) => item.manifest.artifacts)].flat());
99
+ // R15-2: only force "running" when the disk manifest is NON-terminal
100
+ // (queued/planning/running/blocked). If the disk status is already terminal
101
+ // (cancelled/failed/completed — an external cancel or reconciler write
102
+ // landing during the batch), PRESERVE that terminal status: forcing
103
+ // "running" would legally erase it (contracts.ts allows cancelled/failed/
104
+ // completed → running) and the loop would never observe the disk-terminal
105
+ // (CANCEL-1/CANCEL-2 only catch worker-reported cancel or signal abort).
106
+ const mergedBase = { ...diskManifest, artifacts: reconciledArtifacts };
107
+ const resultManifest = isRunTerminalPreserved(diskManifest.status)
108
+ ? mergedBase
109
+ : updateRunStatus(mergedBase, "running", "Merged task updates from parallel batch.");
110
+ // CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
111
+ // of the in-memory `tasks` closure variable. The in-memory tasks reflect
112
+ // only team-runner's view; an external cancel (handleCancel, background
113
+ // race with SIGTERM arriving after cancel wrote but before merge ran)
114
+ // writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
115
+ // that cancellation through the merge instead of overwriting it with the
116
+ // stale in-memory view. disk was loaded inside this lock, so it reflects
117
+ // the freshest committed state.
118
+ const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
119
+ await saveRunManifestAsync(resultManifest);
120
+ await saveRunTasksAsync(resultManifest, resultTasks);
121
+ return { resultManifest, resultTasks };
122
+ });
123
+ ctx.manifest = mergeResult.resultManifest;
124
+ ctx.tasks = mergeResult.resultTasks;
125
+ ctx.settledMerge = { taskIds: completedUnit.taskIds, result: resultToMerge };
126
+ return null;
127
+ }
128
+
129
+ /** @internal 1.9(b) test export — exercise mergeUnitResult directly. */
130
+ export const __test__mergeUnitResult = mergeUnitResult;