pi-crew 0.9.68 → 0.10.2

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 (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -25,11 +25,26 @@ export interface SubagentSpawnOptions {
25
25
  /** Optional batch grouping id (Rule 1). Agents sharing a batchId coalesce
26
26
  * completion notifications into one. undefined => individual (default). */
27
27
  batchId?: string;
28
+ /** WP-1/R1 (H6): owning team-run task id. Set by the one-shot Agent-tool
29
+ * route once the run manifest resolves (taskId is only knowable after the
30
+ * run is dispatched). undefined for unlinked/legacy spawns. */
31
+ taskId?: string;
32
+ /** WP-1/R1 (H6): ownership depth in the task tree (0 for a root one-shot).
33
+ * Optional for back-compat; defaults to 0 at spawn. WP-5 owns depth
34
+ * semantics for nested runs. */
35
+ depth?: number;
28
36
  }
29
37
 
30
38
  export interface SubagentRecord {
31
39
  id: string;
32
40
  runId?: string;
41
+ /** WP-1/R1 (H6): owning team-run task id (task ⇄ subagentId link).
42
+ * Optional for back-compat — records without it render as today and steer
43
+ * returns the existing "not linked" message (no throw). */
44
+ taskId?: string;
45
+ /** WP-1/R1 (H6): ownership depth (0 for root one-shot). Optional for
46
+ * back-compat — legacy records without it render as today. */
47
+ depth?: number;
33
48
  type: string;
34
49
  description: string;
35
50
  prompt: string;
@@ -144,6 +159,7 @@ const ALLOWED_RECORD_FIELDS = new Set([
144
159
  "runId",
145
160
  "cwd",
146
161
  "taskId",
162
+ "depth",
147
163
  "result",
148
164
  "error",
149
165
  "resultConsumed",
@@ -185,7 +201,13 @@ export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRe
185
201
  try {
186
202
  const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
187
203
  return sanitizePersistedRecord(raw);
188
- } catch {
204
+ } catch (error) {
205
+ // R17-B5: parse/read failures were fully silent, hiding lost records
206
+ // (see NEW-R3 note in savePersistedSubagentRecord). ENOENT stays quiet —
207
+ // a legitimately removed record (cancel) is not an error.
208
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
209
+ logInternalError("subagent-manager.read-persisted", error, `id=${id}`, "warn");
210
+ }
189
211
  return undefined;
190
212
  }
191
213
  }
@@ -254,6 +276,8 @@ export class SubagentManager {
254
276
  ownerSessionGeneration: options.ownerSessionGeneration,
255
277
  ownerSessionId: options.ownerSessionId,
256
278
  batchId: options.batchId,
279
+ taskId: options.taskId,
280
+ depth: options.depth ?? 0,
257
281
  };
258
282
  this.records.set(record.id, record);
259
283
  this.cwdByRecord.set(record.id, options.cwd);
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DEFAULT_OUTPUT_CONTEXT } from "../config/defaults.ts";
4
+ import { getCrewEnv } from "../config/env-vars.ts";
4
5
  import { atomicWriteFile } from "../state/atomic-write.ts";
5
6
  import { writeArtifact } from "../state/stores/artifact-store.ts";
6
7
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
@@ -124,6 +125,13 @@ export interface ReadIfSmallTeeResult {
124
125
  content: string;
125
126
  /** Set only when tee was actually written (file size > 2× threshold + write succeeded). */
126
127
  fullOutputPath?: string;
128
+ /** R10-1 (dep-context cache tee-safety): character length of the RAW file
129
+ * content BEFORE the truncation pipeline ran. Populated on every
130
+ * successful read; lets callers tell whether a cached no-tee read
131
+ * (`readIfSmall`) would have teed
132
+ * (length > TEE_THRESHOLD_MULTIPLIER × MAX_RESULT_INLINE_BYTES) without
133
+ * re-reading the file — no heuristics on the truncated body. */
134
+ originalLength?: number;
127
135
  }
128
136
 
129
137
  /**
@@ -211,9 +219,11 @@ export function readIfSmallWithTee(
211
219
  },
212
220
  }),
213
221
  ]);
214
- return fullOutputPath ? { content: result.text, fullOutputPath } : { content: result.text };
222
+ return fullOutputPath
223
+ ? { content: result.text, fullOutputPath, originalLength: content.length }
224
+ : { content: result.text, originalLength: content.length };
215
225
  }
216
- return { content };
226
+ return { content, originalLength: content.length };
217
227
  } catch {
218
228
  return undefined;
219
229
  }
@@ -401,6 +411,12 @@ export function collectDependencyOutputContext(
401
411
  tasks: TeamTaskState[],
402
412
  task: TeamTaskState,
403
413
  step: WorkflowStep,
414
+ /** R10-1 residual: per-run result-artifact read cache (same instance the
415
+ * closeout aggregation uses — created once in executeTeamRunCore, threaded
416
+ * SchedulerContext → baseInput → TaskRunnerInput). Optional: undefined
417
+ * keeps the previous uncached per-dep readIfSmallWithTee behavior
418
+ * byte-for-byte. NEVER pass a per-call cache instance. */
419
+ cache?: ResultArtifactReadCache,
404
420
  ): DependencyOutputContext {
405
421
  const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
406
422
  const byId = new Map(tasks.map((item) => [item.id, item]));
@@ -424,11 +440,19 @@ export function collectDependencyOutputContext(
424
440
  .filter((item): item is TeamTaskState => Boolean(item))
425
441
  .map((item) => {
426
442
  const fullOutputPath = item.resultArtifact ? teePathForArtifact(manifest.artifactsRoot, task.id, item.id) : undefined;
443
+ // R10-1 residual: route the dep result read through the same per-run
444
+ // cache the closeout aggregation populated (readTaskResultArtifactWithTee).
445
+ // Without a cache this issues the identical readIfSmallWithTee call as
446
+ // before (byte-identical output); with one, a non-tee-band hit returns
447
+ // the memoized body while tee-band truncations still do the real read
448
+ // (tee file + fullOutputPath preserved per consumer).
427
449
  const teeResult = item.resultArtifact
428
- ? readIfSmallWithTee(item.resultArtifact.path, {
429
- baseDir: manifest.artifactsRoot,
430
- ...(fullOutputPath ? { tee: { fullOutputPath } } : {}),
431
- })
450
+ ? readTaskResultArtifactWithTee(
451
+ item.resultArtifact,
452
+ manifest.artifactsRoot,
453
+ fullOutputPath ? { fullOutputPath } : undefined,
454
+ cache,
455
+ )
432
456
  : undefined;
433
457
  const resultText = teeResult?.content;
434
458
  const inlineBytes = resultText?.length ?? 0;
@@ -570,12 +594,209 @@ export function writeTaskInputsArtifact(
570
594
  });
571
595
  }
572
596
 
573
- export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest): string {
597
+ /**
598
+ * R10-1: outcome of one aggregated result-artifact read (the pair of disk
599
+ * ops {@link aggregateTaskOutputs} performs per task: readIfSmall +
600
+ * containedExists). Cached as a unit so a cache hit replaces BOTH ops.
601
+ */
602
+ export interface ResultArtifactReadOutcome {
603
+ /** Truncated body from readIfSmall; undefined when the read failed/missing. */
604
+ body: string | undefined;
605
+ /** containedExists() result for the same artifact path. */
606
+ exists: boolean;
607
+ /** R10-1 (dep-context tee-safety): character length of the raw file content
608
+ * at miss time. Present whenever the read succeeded; used by the
609
+ * dependency-context seam to decide whether a cache hit is byte-identical
610
+ * (no tee would have been written) or must fall through to a real
611
+ * readIfSmallWithTee. Undefined when the read failed. */
612
+ originalLength?: number;
613
+ }
614
+
615
+ /**
616
+ * R10-1: per-run memoization for result-artifact reads inside
617
+ * {@link aggregateTaskOutputs} (batch summary + group-join both aggregate the
618
+ * SAME settled batch every closeout — the second aggregation re-reads each
619
+ * `results/<taskId>.txt` from disk for no benefit).
620
+ *
621
+ * Cache key = artifact path + descriptor identity (`sizeBytes|contentHash`).
622
+ * writeArtifact computes both on every write (STATE-9: in-memory,
623
+ * post-redaction), so a retry that rewrites `results/<taskId>.txt` produces a
624
+ * NEW descriptor → new key → automatic miss. Descriptors missing BOTH fields
625
+ * bypass the cache entirely (cannot distinguish a rewrite from unchanged
626
+ * content). "Missing" outcomes are cached too (result artifacts use the
627
+ * default `retention: "run"` and are not pruned mid-run, so a miss is stable
628
+ * within a run). Path is used as-is (producer/taskId-keyed, stable absolute
629
+ * path from the manifest — no per-lookup realpath needed).
630
+ *
631
+ * Lifetime: one run (closure-level in executeTeamRunCore) — no cross-run
632
+ * leakage, no invalidation beyond the descriptor-identity key.
633
+ *
634
+ * Env bypass: `PI_CREW_DISABLE_RESULT_READ_CACHE=1` (read ONCE at creation)
635
+ * returns a cache whose lookups always miss — control mode for benches/tests
636
+ * that need the uncached behavior while still routing through the counting
637
+ * wrapper below.
638
+ */
639
+ export interface ResultArtifactReadCache {
640
+ /** Cached outcome for the artifact identity, or undefined on miss/bypass. */
641
+ lookup(descriptor: ArtifactDescriptor): ResultArtifactReadOutcome | undefined;
642
+ /** Store an outcome. No-op when the descriptor lacks identity fields. */
643
+ store(descriptor: ArtifactDescriptor, outcome: ResultArtifactReadOutcome): void;
644
+ }
645
+
646
+ /**
647
+ * R10-1 test/bench counters for the result-artifact read path. Track disk ops
648
+ * ACTUALLY ISSUED through {@link readTaskResultArtifact} (incremented in the
649
+ * miss branch right before the real readIfSmall/containedExists calls) plus
650
+ * cache hits/misses. The disabled-control mode still routes through the
651
+ * counting wrapper (map simply not consulted) so cached-vs-uncached
652
+ * comparisons are honest.
653
+ */
654
+ export interface ResultArtifactReadStats {
655
+ readFile: number;
656
+ exists: number;
657
+ hits: number;
658
+ misses: number;
659
+ reset(): void;
660
+ }
661
+
662
+ export const __test__resultReadStats: ResultArtifactReadStats = {
663
+ readFile: 0,
664
+ exists: 0,
665
+ hits: 0,
666
+ misses: 0,
667
+ reset() {
668
+ this.readFile = 0;
669
+ this.exists = 0;
670
+ this.hits = 0;
671
+ this.misses = 0;
672
+ },
673
+ };
674
+
675
+ function resultArtifactCacheKey(descriptor: ArtifactDescriptor): string | undefined {
676
+ if (descriptor.sizeBytes === undefined && descriptor.contentHash === undefined) return undefined;
677
+ return `${descriptor.path}|${descriptor.sizeBytes ?? ""}|${descriptor.contentHash ?? ""}`;
678
+ }
679
+
680
+ export function createResultArtifactReadCache(): ResultArtifactReadCache {
681
+ // Read once at creation — the bypass decision is fixed for the cache's
682
+ // (per-run) lifetime, matching PI_CREW_USE_BUNDLE-style one-shot env reads.
683
+ if (getCrewEnv("PI_CREW_DISABLE_RESULT_READ_CACHE") === "1") {
684
+ return {
685
+ lookup: () => undefined,
686
+ store: () => undefined,
687
+ };
688
+ }
689
+ const entries = new Map<string, ResultArtifactReadOutcome>();
690
+ return {
691
+ lookup(descriptor) {
692
+ const key = resultArtifactCacheKey(descriptor);
693
+ if (key === undefined) return undefined;
694
+ const hit = entries.get(key);
695
+ if (hit === undefined) {
696
+ __test__resultReadStats.misses += 1;
697
+ return undefined;
698
+ }
699
+ __test__resultReadStats.hits += 1;
700
+ return hit;
701
+ },
702
+ store(descriptor, outcome) {
703
+ const key = resultArtifactCacheKey(descriptor);
704
+ if (key !== undefined) entries.set(key, outcome);
705
+ },
706
+ };
707
+ }
708
+
709
+ /**
710
+ * Single read seam for {@link aggregateTaskOutputs}. Without a cache this is
711
+ * byte-for-byte the old behavior: readIfSmall + containedExists issued in the
712
+ * same order. With a cache, a hit returns the memoized pair (zero disk ops);
713
+ * a miss issues the same disk ops as the uncached path, counts them, and
714
+ * memoizes the outcome.
715
+ */
716
+ function readTaskResultArtifact(
717
+ descriptor: ArtifactDescriptor,
718
+ baseDir: string | undefined,
719
+ cache: ResultArtifactReadCache | undefined,
720
+ ): ResultArtifactReadOutcome {
721
+ const cached = cache?.lookup(descriptor);
722
+ if (cached !== undefined) return cached;
723
+ __test__resultReadStats.readFile += 1;
724
+ __test__resultReadStats.exists += 1;
725
+ // readIfSmall is a thin wrapper over readIfSmallWithTee(...).content — the
726
+ // direct call is byte-identical and also captures originalLength (R10-1
727
+ // tee-safety metadata consumed by readTaskResultArtifactWithTee).
728
+ const raw = readIfSmallWithTee(descriptor.path, { baseDir });
729
+ const outcome: ResultArtifactReadOutcome = {
730
+ body: raw?.content,
731
+ exists: containedExists(descriptor.path, baseDir),
732
+ ...(raw?.originalLength !== undefined ? { originalLength: raw.originalLength } : {}),
733
+ };
734
+ cache?.store(descriptor, outcome);
735
+ return outcome;
736
+ }
737
+
738
+ /**
739
+ * R10-1 residual: cached read seam for the dependency-context path
740
+ * ({@link collectDependencyOutputContext}). Mirrors {@link readTaskResultArtifact}
741
+ * while preserving the tee-recovery semantics of the direct
742
+ * `readIfSmallWithTee(path, { tee })` call it replaces:
743
+ *
744
+ * - A cache hit is reusable ONLY when the memoized read proves the uncached
745
+ * call would NOT have teed: `originalLength` is known AND
746
+ * `originalLength <= TEE_THRESHOLD_MULTIPLIER × MAX_RESULT_INLINE_BYTES`.
747
+ * In that band readIfSmallWithTee().content is byte-identical to the
748
+ * memoized body (same file, same truncation pipeline) and fullOutputPath is
749
+ * unset, so returning the memoized body cannot change the rendered prompt.
750
+ * - A memoized read that FAILED (body undefined) is also returned as-is: the
751
+ * direct readIfSmallWithTee would have thrown and returned undefined for
752
+ * the same descriptor identity.
753
+ * - Otherwise (miss, bypass, or tee-band truncation) the real
754
+ * readIfSmallWithTee runs — WITH tee, so the per-consumer
755
+ * `tee/<taskId>-<depId>.full.txt` write and `fullOutputPath` survive — and
756
+ * the disk ops are counted in `__test__resultReadStats` exactly like
757
+ * readTaskResultArtifact's miss branch (honest cached-vs-bypass benches).
758
+ * When a cache is provided the miss ALSO populates it with the SAME
759
+ * outcome readTaskResultArtifact would have stored (body = teeResult.content
760
+ * — byte-identical to readIfSmall's output since tee never alters content —
761
+ * exists via containedExists, originalLength), so the next dep collect /
762
+ * closeout aggregation hits. Truncation checks in later consumers use
763
+ * originalLength, never heuristics on the truncated body.
764
+ */
765
+ function readTaskResultArtifactWithTee(
766
+ descriptor: ArtifactDescriptor,
767
+ baseDir: string | undefined,
768
+ tee: TeeRecoveryOptions | undefined,
769
+ cache: ResultArtifactReadCache | undefined,
770
+ ): ReadIfSmallTeeResult | undefined {
771
+ const cached = cache?.lookup(descriptor);
772
+ if (cached !== undefined) {
773
+ if (cached.body === undefined) return undefined;
774
+ if (cached.originalLength !== undefined && cached.originalLength <= MAX_RESULT_INLINE_BYTES * TEE_THRESHOLD_MULTIPLIER) {
775
+ return { content: cached.body };
776
+ }
777
+ }
778
+ __test__resultReadStats.readFile += 1;
779
+ const result = readIfSmallWithTee(descriptor.path, { baseDir, ...(tee ? { tee } : {}) });
780
+ if (cache) {
781
+ // Populate with the outcome the closeout seam would have stored. The op
782
+ // order (read → existsSync) matches readTaskResultArtifact's miss branch.
783
+ __test__resultReadStats.exists += 1;
784
+ cache.store(descriptor, {
785
+ body: result?.content,
786
+ exists: containedExists(descriptor.path, baseDir),
787
+ ...(result?.originalLength !== undefined ? { originalLength: result.originalLength } : {}),
788
+ });
789
+ }
790
+ return result;
791
+ }
792
+
793
+ export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest, cache?: ResultArtifactReadCache): string {
574
794
  return tasks
575
795
  .map((task, index) => {
576
- const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest?.artifactsRoot) : undefined;
796
+ const read = task.resultArtifact ? readTaskResultArtifact(task.resultArtifact, manifest?.artifactsRoot, cache) : undefined;
797
+ const body = read?.body;
577
798
  const hasBody = Boolean(body?.trim());
578
- const expectedMissing = task.resultArtifact && !containedExists(task.resultArtifact.path, manifest?.artifactsRoot);
799
+ const expectedMissing = read ? !read.exists : undefined;
579
800
  const status =
580
801
  task.status === "skipped"
581
802
  ? "SKIPPED"
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import type { TaskPacket, TaskScope, TeamRunManifest, VerificationContract } from "../state/types.ts";
2
+ import { freezeSpecSnapshot, loadSpecRecord } from "../state/stores/spec-store.ts";
3
+ import type { SpecRecord, TaskPacket, TaskScope, TeamRunManifest, VerificationContract } from "../state/types.ts";
3
4
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
4
5
  import { generateTaskHashId } from "./task-id.ts";
5
6
 
@@ -70,6 +71,10 @@ export function sanitizeTaskText(task: string): string {
70
71
  }
71
72
 
72
73
  export interface BuildTaskPacketInput {
74
+ /** T4/R6 (ADR-6 §2): workspace spec ids — loaded + FROZEN into the packet. */
75
+ specRefs?: string[];
76
+ /** T4/R6 (ADR-6 §7): strict mode — coverage AND machine-check (§4). */
77
+ specStrict?: boolean;
73
78
  manifest: TeamRunManifest;
74
79
  step: WorkflowStep;
75
80
  taskId: string;
@@ -98,6 +103,20 @@ export function defaultVerificationContract(step: WorkflowStep): VerificationCon
98
103
  }
99
104
 
100
105
  export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
106
+ // T4/R6 (ADR-6 §1/§2): the ONLY packet-creation path is also the freeze
107
+ // point — specRefs load from the workspace store and freeze into immutable
108
+ // snapshots embedded in the packet (later spec edits never rewrite them).
109
+ // Round-1 review (fail-closed freeze): declared-but-unresolvable ids are
110
+ // kept on the packet as unresolvedSpecRefs instead of being dropped — the
111
+ // gate surfaces them (badge non-strict / failure strict), never a silent
112
+ // no-op. Snapshots freeze trust via the store at THIS moment.
113
+ const declaredSpecRefs = input.specRefs ?? [];
114
+ const resolvedRecords = declaredSpecRefs
115
+ .map((id) => loadSpecRecord(input.manifest.cwd, id))
116
+ .filter((r): r is SpecRecord => r !== undefined);
117
+ const specSnapshots = resolvedRecords.map((r) => freezeSpecSnapshot(r, input.manifest.cwd));
118
+ const unresolvedSpecRefs = declaredSpecRefs.filter((id) => !specSnapshots.some((s) => s.specId === id));
119
+
101
120
  const scope = inferTaskScope(input.step);
102
121
  const reads = input.step.reads === false ? [] : (input.step.reads ?? []);
103
122
  const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
@@ -112,6 +131,9 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
112
131
 
113
132
  return {
114
133
  objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
134
+ ...(declaredSpecRefs.length > 0 ? { specRefs: declaredSpecRefs, specSnapshots } : {}),
135
+ ...(unresolvedSpecRefs.length > 0 ? { unresolvedSpecRefs } : {}),
136
+ ...(input.specStrict === true ? { specStrict: true } : {}),
115
137
  scope,
116
138
  scopePath,
117
139
  repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
@@ -28,17 +28,21 @@
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
32
  import { errors } from "../../errors.ts";
32
33
  import { appendEventAsync, appendEventBuffered } from "../../state/event-log/event-log.ts";
33
34
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
35
+ import { upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
34
36
  import type { ArtifactDescriptor, OperationTerminalEvidence, TeamRunManifest } from "../../state/types.ts";
37
+ import { type FatalFsCause, failureCauseForAttempt } from "../../utils/fs-errno.ts";
35
38
  import { logInternalError } from "../../utils/internal-error.ts";
36
39
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
37
40
  import type { ChildPiLifecycleEvent, ChildPiRunResult } from "../child-pi/child-pi.ts";
38
41
  import {
39
- appendCrewAgentEvent,
40
- appendCrewAgentOutput,
42
+ appendCrewAgentEventBuffered,
43
+ appendCrewAgentOutputBuffered,
41
44
  emptyCrewAgentProgress,
45
+ flushCrewAgentRecordBuffer,
42
46
  recordFromTask,
43
47
  upsertCrewAgent,
44
48
  } from "../crew-agent-records.ts";
@@ -380,6 +384,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
380
384
  }
381
385
  const logs: string[] = [];
382
386
  let finalStderr = "";
387
+ // bug-026 sub-issue B: fatal-fs cause (enospc/edquot/emfile/enfile)
388
+ // derived per attempt from the attempt error / stderr tail.
389
+ let failureCause: FatalFsCause | undefined;
383
390
  // One-shot: the re-resolve is a safety net for a chain that was computed
384
391
  // before the registry was complete, not a retry loop of its own.
385
392
  let reResolveUsed = false;
@@ -445,6 +452,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
445
452
  lastRunProgressPersistedAt = now;
446
453
  }
447
454
  };
455
+ // F7 (B1 battery 2026-08-18): one dispatch timestamp for the WHOLE attempt
456
+ // loop. A steer racing anywhere between task-dispatch and any attempt's
457
+ // onSpawn (including across the model-fallback respawn gap — observed live:
458
+ // steer at 15:59:11 wiped by attempt-1's truncate at 15:59:59) must survive
459
+ // the per-incarnation truncate; residue older than the dispatch is wiped.
460
+ const taskDispatchStartedAtMs = Date.now();
448
461
  for (let i = 0; i < attemptModels.length; i++) {
449
462
  // M1 fix: set transcript path per attempt to avoid mixing across fallback attempts.
450
463
  transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.attempt-${i}.jsonl`;
@@ -549,10 +562,65 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
549
562
  steeringFile: resolveRealContainedPath(`${manifest.artifactsRoot}/steering`, `${task.id}.jsonl`),
550
563
  onSpawn: (pid) => {
551
564
  try {
565
+ // WP-1/R1 (H6): dispatch-time ownership writer — record the
566
+ // task ⇄ pid ⇄ artifactsDir leg of the ownership map as soon as
567
+ // a real worker spawns. The one-shot Agent-tool path fills the
568
+ // subagentId leg on the same taskId; the store merges per field
569
+ // under the run lock. Best-effort: never throws into the spawn
570
+ // path. onSpawn fires per spawn attempt (model-fallback loop), so
571
+ // the pid is overwritten with the latest attempt's live pid —
572
+ // correct, that is the worker steer targets.
573
+ try {
574
+ upsertOwnershipEntry(manifest, {
575
+ taskId: task.id,
576
+ runId: manifest.runId,
577
+ pid,
578
+ artifactsDir: manifest.artifactsRoot,
579
+ updatedAt: new Date().toISOString(),
580
+ });
581
+ } catch (err) {
582
+ logInternalError("task-runner.ownership-write", err, `pid=${pid}, taskId=${task.id}`);
583
+ }
552
584
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
585
+ // Security review (T1/WP-1 round 1, security-1 replay fix): scope the
586
+ // steering file to ONE worker incarnation on EVERY spawn. Steering
587
+ // lines are id-less; each new worker process reads the file from
588
+ // byte 0 (prompt-runtime poll `lastOffset = 0`), so un-truncated
589
+ // files re-deliver every accumulated line — including stale steers
590
+ // written after the run went terminal — into the next spawn
591
+ // attempt's first-turn context. Best-effort; never throws.
592
+ // ACCEPTED TRADE-OFF (security review round 2): a steer appended
593
+ // directly by steer_subagent to a worker that dies BEFORE its 500ms
594
+ // poll reads it is lost when the respawn truncates here. The reliable
595
+ // cross-attempt channel is task.pendingSteers (survives in the task
596
+ // record and is re-appended after truncation); the direct file append
597
+ // is best-effort live-delivery only.
598
+ {
599
+ const steeringDir = `${manifest.artifactsRoot}/steering`;
600
+ try {
601
+ const safeSteeringPath = resolveRealContainedPath(steeringDir, `${task.id}.jsonl`);
602
+ // F7 fix: skip the truncate when the file's last write happened at or
603
+ // after THIS task's dispatch began — that is a live steer racing the
604
+ // spawn (observed live: "Steer delivered" + 0-byte file). Residue
605
+ // predating the dispatch (stale terminal-era steers) is still wiped.
606
+ // stat→truncate gap is microseconds; a steer landing inside it is
607
+ // lost — best-effort, same trade-off as before.
608
+ let racedSteerArrived = false;
609
+ try {
610
+ racedSteerArrived = fs.statSync(safeSteeringPath).mtimeMs >= taskDispatchStartedAtMs - 1;
611
+ } catch {
612
+ /* no file yet — nothing to truncate */
613
+ }
614
+ if (!racedSteerArrived) fs.writeFileSync(safeSteeringPath, "");
615
+ } catch (truncateErr) {
616
+ logInternalError("task-runner.steering-truncate", truncateErr, `taskId=${task.id}`);
617
+ }
618
+ }
553
619
  if (task.pendingSteers?.length) {
554
620
  const steeringDir = `${manifest.artifactsRoot}/steering`;
555
- // Fire-and-forget async write for steering events
621
+ // Fire-and-forget async write for steering events (the file was
622
+ // truncated just above; this incarnation receives only its own
623
+ // pending steers).
556
624
  void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
557
625
  // RT-8: spread before clearing pendingSteers instead of mutating
558
626
  // in place — preserves the immutable-snapshot invariant (the same
@@ -574,7 +642,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
574
642
  }).catch((error) => logInternalError("task-runner.lifecycle-event", error, `taskId=${task.id}, type=${event.type}`));
575
643
  },
576
644
  onStdoutLine: (line) => {
577
- appendCrewAgentOutput(manifest, task.id, line);
645
+ // R10-5 (Wave 2B item 4): buffered — one appendFileSync per flush
646
+ // window instead of one per stdout line. Flushes at task boundary
647
+ // (finally below), process exit, 32-event cap, or 250ms window.
648
+ appendCrewAgentOutputBuffered(manifest, task.id, line);
578
649
  persistHeartbeat();
579
650
  },
580
651
  onJsonEvent: (event) => {
@@ -585,7 +656,11 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
585
656
  // the full payload through); was dead on the old displayLine path.
586
657
  const contact = supervisorContactFromEvent(event);
587
658
  if (contact) recordSupervisorContact(manifest, { runId: manifest.runId, ...contact });
588
- appendCrewAgentEvent(manifest, task.id, event);
659
+ // R10-5 (Wave 2B item 4): buffered agent-record sink — collapses the
660
+ // per-event stat+append fanout into one appendFileSync per flush
661
+ // window. Seqs are reserved at buffer time (no collisions); the
662
+ // run-level events.jsonl, steering, and result.md stay unbuffered.
663
+ appendCrewAgentEventBuffered(manifest, task.id, event);
589
664
  if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event))
590
665
  collectedJsonEvents.push(event as Record<string, unknown>);
591
666
  if (collectedJsonEvents && collectedJsonEvents.length > 1000) {
@@ -613,7 +688,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
613
688
  persistHeartbeat();
614
689
  // Bug #3 fix: Write worker JSON events to background.log for debugging when running in background mode.
615
690
  // This supplements the event log so developers can see what the child Pi worker produced.
616
- if (process.env.PI_CREW_BACKGROUND_MODE === "1" && event) {
691
+ if (getCrewEnv("PI_CREW_BACKGROUND_MODE") === "1" && event) {
617
692
  const bgLogPath = `${manifest.stateRoot}/background.log`;
618
693
  const eventLine = typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
619
694
  // Fire-and-forget async write for background log
@@ -650,6 +725,12 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
650
725
  },
651
726
  });
652
727
  } finally {
728
+ // R10-5 (Wave 2B item 4): task-boundary flush — land every buffered
729
+ // agent-record line from this attempt's callbacks before terminal
730
+ // state is derived/read. Runs on completion, failure, AND throw, so
731
+ // the persisted events/output after a task always match what the
732
+ // unbuffered implementation would have written.
733
+ flushCrewAgentRecordBuffer(manifest, task.id);
653
734
  if (timeoutHandle) clearTimeout(timeoutHandle);
654
735
  // W2 fix — release the listener so it doesn't leak. {once:true}
655
736
  // only auto-removes when the listener FIRES; if the timeout
@@ -712,6 +793,13 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
712
793
  rawFinalText = childResult.rawFinalText;
713
794
  intermediateFindings = childResult.intermediateFindings;
714
795
  error = attemptErrorFor(childResult, parsedOutput, task.id);
796
+ // bug-026 sub-issue B: classify fatal fs errnos (ENOSPC/EDQUOT/EMFILE/
797
+ // ENFILE). During the 2026-08-15 disk-full window the errno only surfaced
798
+ // inside E007 stderr-tail strings, never as a raw errno object in the
799
+ // parent — so match the message text too. Recomputed every attempt;
800
+ // stamped on the task record immediately so a crash before loop exit
801
+ // still persists the cause. Only meaningful on failure.
802
+ failureCause = error ? failureCauseForAttempt(error, finalStderr) : undefined;
715
803
  persistHeartbeat(true);
716
804
  persistChildProgress({ type: "attempt_finished" }, true);
717
805
  const attempt: ModelAttemptSummary = {
@@ -721,7 +809,11 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
721
809
  error,
722
810
  };
723
811
  modelAttempts.push(attempt);
724
- task = { ...task, modelAttempts: [...modelAttempts] };
812
+ task = {
813
+ ...task,
814
+ modelAttempts: [...modelAttempts],
815
+ ...(failureCause ? { failureCause } : {}),
816
+ };
725
817
  tasks = updateTask(tasks, task);
726
818
  logs.push(
727
819
  `MODEL ATTEMPT ${i + 1}: ${attempt.model}`,
@@ -791,6 +883,11 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
791
883
  error,
792
884
  ).message;
793
885
  }
886
+ // bug-026 sub-issue B: reclassify after the E008 modelExhausted rewrite —
887
+ // its message embeds the last attempt's error (incl. stderr tails), so the
888
+ // errno survives the rewrite. Cleared on success (a task that recovered on
889
+ // retry is not an fs failure).
890
+ failureCause = error ? failureCauseForAttempt(error, finalStderr) : undefined;
794
891
  // NEW-8 fix: register all attempt transcripts as artifacts, not just the used one.
795
892
  // Earlier failed attempts' transcripts exist on disk but were invisible to the artifact system.
796
893
  const successfulAttemptIndex = modelAttempts.findIndex((attempt) => attempt.success);
@@ -850,6 +947,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
850
947
  const fallbackReason = usedAttempt > 0 ? modelAttempts[usedAttempt - 1]?.error : undefined;
851
948
  task = {
852
949
  ...task,
950
+ ...(failureCause ? { failureCause } : {}),
853
951
  modelRouting: {
854
952
  requested: modelRoutingPlan.requested,
855
953
  resolved: resolvedModel,
@@ -915,6 +1013,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
915
1013
  modelAttempts,
916
1014
  parsedOutput,
917
1015
  finalStdout,
1016
+ rawFinalText,
918
1017
  transcriptPath,
919
1018
  terminalEvidence,
920
1019
  startupEvidence,