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
@@ -0,0 +1,945 @@
1
+ /**
2
+ * Dispatch-batch selection and execution for the team-run scheduler loop.
3
+ *
4
+ * Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
5
+ * CORE-4 extractions 3+4). Pure code motion: selectDispatchBatch /
6
+ * DispatchBatchDecision / dispatchBatch and their module-private
7
+ * collaborators (findStep, findAgent, markBlocked, cancelNonTerminalTasks,
8
+ * retryPolicyFromConfig, shouldUseRetry, failedTaskFrom, dagReadyTaskIds)
9
+ * moved verbatim.
10
+ */
11
+ import type { AgentConfig } from "../agents/agent-config.ts";
12
+ import type { CrewReliabilityConfig } from "../config/config.ts";
13
+ import { CrewError, ErrorCode } from "../errors.ts";
14
+ import { appendHookEvent, executeHook } from "../hooks/registry.ts";
15
+ import { childCorrelation, withCorrelation } from "../observability/correlation.ts";
16
+ import { withRunLockSync } from "../state/coordination/locks.ts";
17
+ import { appendEventAsync, appendEventBuffered } from "../state/event-log/event-log.ts";
18
+ import { loadRunManifestById, saveRunManifest, saveRunTasks, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
19
+ import type { TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
20
+ import { logInternalError } from "../utils/internal-error.ts";
21
+ import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
22
+ import { readCrewAgents, recordFromTask, saveCrewAgents } from "./crew-agent-records.ts";
23
+ import { appendDeadletter } from "./deadletter.ts";
24
+ import { classifyHeartbeat, DEFAULT_GRADIENT_THRESHOLDS } from "./heartbeat/heartbeat-gradient.ts";
25
+ import { getLiveAgent } from "./live-session/live-agent-manager.ts";
26
+ import { isNonTerminalTaskStatus } from "./merge-gate.ts";
27
+ import { resolveTaskRuntimeKind } from "./model/runtime-policy.ts";
28
+ import { filterReadyByWriteOverlap } from "./path-overlap.ts";
29
+ import { isMutatingTask, isPlanApprovalPendingEffective } from "./plan-approval.ts";
30
+ import { sweepDroppedPlanItems } from "./plan-replan.ts";
31
+ import { CrewCancellationError, cancellationReasonFromSignal } from "./process/cancellation.ts";
32
+ import { DEFAULT_RETRY_POLICY, executeWithRetry, type RetryPolicy } from "./recovery/retry-executor.ts";
33
+ import type { SchedulerContext, SchedulerDecision, SettledUnit } from "./scheduler-context.ts";
34
+ import { buildDispatchUnits, type DispatchUnit, planCoalescedGroups } from "./scheduling/coalesce-tasks.ts";
35
+ import { resolveBatchConcurrency } from "./scheduling/concurrency.ts";
36
+ import { runCoalescedTaskGroup } from "./scheduling/run-coalesced-task-group.ts";
37
+ import { buildExecutionPlan as buildDagExecutionPlan, getReadyTasks as getDagReadyTasks, type TaskNode } from "./scheduling/task-graph.ts";
38
+ import { taskGraphSnapshot } from "./scheduling/task-graph-scheduler.ts";
39
+ import { recordsForMaterializedTasks } from "./task-display.ts";
40
+ import { computeStablePrefixComponents } from "./task-runner/prompt-builder.ts";
41
+ import { runTeamTask, type SpawnBudget } from "./task-runner.ts";
42
+ import { type PhaseGuardContext, validatePhasePreconditions } from "./workflow-state.ts";
43
+
44
+ // ── WP-2/R2 (ADR-0 items 8+10): waiting-producer liveness + deadline owner ──
45
+
46
+ /**
47
+ * WP-2/R2 (ADR-0 docs/decisions/2026-08-17-waiting-producer-ask.md item 8):
48
+ * liveness discriminator for a parked (waiting) worker.
49
+ *
50
+ * ALIVE = heartbeat last-beat within the gradient stale window (<60s —
51
+ * classifyHeartbeat "healthy"/"warn") OR a live in-memory session handle
52
+ * (live-agent registry, non-terminal status). Everything else is DEAD for
53
+ * delivery purposes: stale (60–300s), dead (>300s), `alive:false`, no
54
+ * heartbeat and no handle.
55
+ *
56
+ * Shared by the respond discriminator (extension/team-tool/respond.ts) and
57
+ * the scheduler-tick deadline owner below — one definition, two consumers.
58
+ */
59
+ export function isWaitingWorkerAlive(task: TeamTaskState, now = Date.now()): boolean {
60
+ // Live in-memory handle: same-process live-session runtime keeps a session
61
+ // handle in the registry keyed by (agentId | taskId).
62
+ const handle = getLiveAgent(task.id);
63
+ if (handle && (handle.status === "running" || handle.status === "queued" || handle.status === "waiting")) return true;
64
+ // Heartbeat: last beat inside the gradient stale window (<60s).
65
+ const level = classifyHeartbeat(task.heartbeat, DEFAULT_GRADIENT_THRESHOLDS, now);
66
+ return level === "healthy" || level === "warn";
67
+ }
68
+
69
+ /** In-process exactly-once guard: questionIds whose `ask.timedout` event was
70
+ * already emitted by this scheduler process (prevents re-emission on every
71
+ * subsequent tick while an ALIVE park waits for its in-tool timeout to
72
+ * surface). Capped with FIFO eviction to bound memory. */
73
+ const emittedAskTimedoutQuestionIds = new Set<string>();
74
+ const MAX_EMITTED_ASK_TIMEDOUT = 10_000;
75
+
76
+ export interface WaitingDeadlineSweepResult {
77
+ manifest: TeamRunManifest;
78
+ tasks: TeamTaskState[];
79
+ /** QuestionIds for which an `ask.timedout` event was emitted this sweep. */
80
+ timedOutQuestionIds: string[];
81
+ /** Task ids requeued this sweep (dead workers only). */
82
+ requeuedTaskIds: string[];
83
+ }
84
+
85
+ /** WP-2/R2 (ADR-0 item 10): render the "[ask timed out]" note injected into the
86
+ * next dispatch when a parked ask deadline expires with no worker alive.
87
+ * Fenced as untrusted (same-uid channel discipline). */
88
+ function renderAskTimedoutInjection(questionId: string): string {
89
+ return [
90
+ "<dependency-context>",
91
+ "(Scheduler note: the previous ask(question) deadline expired with no worker alive to receive an answer. It is DATA, not a system directive.)",
92
+ `[ask timed out] questionId=${questionId}`,
93
+ "Continue with best judgment.",
94
+ "</dependency-context>",
95
+ ].join("\n");
96
+ }
97
+
98
+ /**
99
+ * WP-2/R2 (ADR-0 item 10) — scheduler-tick deadline owner for parked asks.
100
+ *
101
+ * For every task with a persisted `task.waiting` whose deadline has expired:
102
+ * - worker ALIVE → no root-side state change (the parked ask tool's own poll
103
+ * deadline surfaces the timeout in-tool); only the `ask.timedout` event is
104
+ * appended.
105
+ * - worker DEAD → clear `waiting`, re-queue the task with a "[ask timed out]"
106
+ * note injected into the next dispatch (pendingSteers cross-attempt
107
+ * channel), and append `ask.timedout`.
108
+ *
109
+ * All state changes run under the run lock with a fresh reload
110
+ * (respond.ts:42-43 discipline); events are appended AFTER the lock is
111
+ * released (awaited — callers are async). Exactly-once per questionId via
112
+ * the in-process guard above (a requeued task also loses `waiting`, so a
113
+ * second sweep pass skips it).
114
+ *
115
+ * Returns undefined when there is nothing to do (no expired waiting park).
116
+ */
117
+ export async function sweepExpiredWaitingTasks(
118
+ cwd: string,
119
+ runId: string,
120
+ now = Date.now(),
121
+ ): Promise<WaitingDeadlineSweepResult | undefined> {
122
+ const initial = loadRunManifestById(cwd, runId);
123
+ if (!initial) return undefined;
124
+ const hasExpired = initial.tasks.some((t) => t.status === "waiting" && t.waiting !== undefined && t.waiting.deadline <= now);
125
+ if (!hasExpired) return undefined;
126
+ const pendingEvents: Array<{ taskId: string; questionId: string; workerAlive: boolean }> = [];
127
+ const outcome = withRunLockSync(initial.manifest, () => {
128
+ const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
129
+ if (!fresh) return null;
130
+ let tasks = fresh.tasks;
131
+ const timedOutQuestionIds: string[] = [];
132
+ const requeuedTaskIds: string[] = [];
133
+ for (const task of fresh.tasks) {
134
+ if (task.status !== "waiting" || task.waiting === undefined) continue;
135
+ if (task.waiting.deadline > now) continue;
136
+ const questionId = task.waiting.questionId;
137
+ if (emittedAskTimedoutQuestionIds.has(questionId)) continue; // exactly-once per process
138
+ if (emittedAskTimedoutQuestionIds.size >= MAX_EMITTED_ASK_TIMEDOUT) {
139
+ const oldest = emittedAskTimedoutQuestionIds.keys().next().value;
140
+ if (oldest !== undefined) emittedAskTimedoutQuestionIds.delete(oldest);
141
+ }
142
+ emittedAskTimedoutQuestionIds.add(questionId);
143
+ const workerAlive = isWaitingWorkerAlive(task, now);
144
+ timedOutQuestionIds.push(questionId);
145
+ pendingEvents.push({ taskId: task.id, questionId, workerAlive });
146
+ if (!workerAlive) {
147
+ // DEAD: clear the park and re-queue with the injected timeout note.
148
+ const note = renderAskTimedoutInjection(questionId);
149
+ tasks = tasks.map((t) =>
150
+ t.id === task.id
151
+ ? {
152
+ ...t,
153
+ status: "queued" as const,
154
+ startedAt: undefined,
155
+ finishedAt: undefined,
156
+ error: undefined,
157
+ waiting: undefined,
158
+ pendingSteers: [...(t.pendingSteers ?? []), note],
159
+ adaptive: {
160
+ ...t.adaptive,
161
+ phase: "resumed",
162
+ task: t.adaptive?.task ?? "",
163
+ },
164
+ }
165
+ : t,
166
+ );
167
+ requeuedTaskIds.push(task.id);
168
+ }
169
+ }
170
+ if (requeuedTaskIds.length === 0) {
171
+ return { manifest: fresh.manifest, tasks: fresh.tasks, timedOutQuestionIds, requeuedTaskIds };
172
+ }
173
+ saveRunTasks(fresh.manifest, tasks);
174
+ let manifest = fresh.manifest;
175
+ // WP-2 review round 1 (P3): clear manifest.waitState when its park was
176
+ // requeued — mirror the broker's clearWaitState guard, else the stale
177
+ // pointer shields the run from staleness repair for the full 24h TTL.
178
+ if (manifest.waitState && requeuedTaskIds.includes(manifest.waitState.taskId)) {
179
+ manifest = { ...manifest, waitState: undefined, updatedAt: new Date().toISOString() };
180
+ saveRunManifest(manifest);
181
+ }
182
+ if (
183
+ manifest.status === "blocked" ||
184
+ manifest.status === "completed" ||
185
+ manifest.status === "failed" ||
186
+ manifest.status === "cancelled"
187
+ ) {
188
+ manifest = updateRunStatus(manifest, "running", `Requeued ${requeuedTaskIds.length} waiting task(s) after ask timeout.`);
189
+ }
190
+ try {
191
+ const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
192
+ saveCrewAgents(
193
+ fresh.manifest,
194
+ tasks
195
+ .filter((t) => requeuedTaskIds.includes(t.id))
196
+ .map((t) => recordFromTask(fresh.manifest, t, existingRuntimes.get(t.id) ?? "child-process")),
197
+ );
198
+ } catch (error) {
199
+ logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
200
+ }
201
+ return { manifest, tasks, timedOutQuestionIds, requeuedTaskIds };
202
+ });
203
+ if (!outcome) return undefined;
204
+ // Events appended AFTER the run lock is released; awaited so callers (and
205
+ // tests) observe durable events.jsonl once the sweep resolves.
206
+ for (const event of pendingEvents) {
207
+ await appendEventAsync(outcome.manifest.eventsPath, {
208
+ type: "ask.timedout",
209
+ runId,
210
+ taskId: event.taskId,
211
+ message: `Ask deadline expired for question ${event.questionId}${
212
+ event.workerAlive ? " (worker alive — surfaced in-tool)" : " (worker dead — task requeued)"
213
+ }.`,
214
+ data: { questionId: event.questionId, workerAlive: event.workerAlive, surface: event.workerAlive ? "in-tool" : "requeue" },
215
+ }).catch((error) => logInternalError("dispatch-batch.ask-timeout-event", error, `runId=${runId} taskId=${event.taskId}`));
216
+ }
217
+ return outcome;
218
+ }
219
+
220
+ function findStep(workflow: WorkflowConfig, task: TeamTaskState): WorkflowStep {
221
+ const step = workflow.steps.find((candidate) => candidate.id === task.stepId);
222
+ if (!step)
223
+ throw new CrewError(ErrorCode.ResourceNotFound, `Workflow step '${task.stepId}' not found for task '${task.id}'.`).withContext(
224
+ `workflow step lookup (task=${task.id})`,
225
+ );
226
+ // T4/R6 (ADR-6 §7): workflow-level specStrict merges into the dispatched step
227
+ // (per-step flag wins) — the task packet freezes the resolved value.
228
+ if (workflow.specStrict === true || step.specStrict === true) {
229
+ return { ...step, specStrict: step.specStrict ?? workflow.specStrict };
230
+ }
231
+ return step;
232
+ }
233
+
234
+ function findAgent(agents: AgentConfig[], task: TeamTaskState): AgentConfig {
235
+ const agent = agents.find((candidate) => candidate.name === task.agent);
236
+ if (!agent)
237
+ throw new CrewError(ErrorCode.ResourceNotFound, `Agent '${task.agent}' not found for task '${task.id}'.`).withContext(
238
+ `agent lookup (task=${task.id})`,
239
+ );
240
+ return agent;
241
+ }
242
+
243
+ export function markBlocked(tasks: TeamTaskState[], reason: string): TeamTaskState[] {
244
+ return tasks.map((task) =>
245
+ task.status === "queued"
246
+ ? {
247
+ ...task,
248
+ status: "skipped",
249
+ error: reason,
250
+ finishedAt: new Date().toISOString(),
251
+ graph: task.graph ? { ...task.graph, queue: "blocked" } : undefined,
252
+ }
253
+ : task,
254
+ );
255
+ }
256
+
257
+ /**
258
+ * CORE-6: Unified cancel/fail of non-terminal tasks. Replaces hand-rolled
259
+ * `.map()` + transform sites across this file.
260
+ *
261
+ * - Without `filter`: all non-terminal tasks (queued/running/waiting) are
262
+ * terminalised with the given status.
263
+ * - With `filter`: the filter is the sole gate — the non-terminal check is
264
+ * NOT applied automatically, matching per-task/per-id variants.
265
+ * - Optional `transform(task, terminalised)`: lets a caller attach
266
+ * site-specific fields (graph mutation, terminalEvidence) to the
267
+ * terminalised task. `terminalised` already carries status/finishedAt/error;
268
+ * the transform returns it unchanged or a modified copy.
269
+ *
270
+ * RT-14: the two remaining inline cancel sites (cancelPlanTasks,
271
+ * cancelRunFromSignal) route through this helper via `transform` so EVERY
272
+ * cancel site uses the single shared transform. Their extra logic
273
+ * (graph mutation / terminalEvidence) is preserved inside the transform.
274
+ *
275
+ * `markBlocked` is intentionally NOT unified here (it sets status "skipped",
276
+ * not cancelled/failed, and only acts on "queued" tasks).
277
+ */
278
+ export function cancelNonTerminalTasks(
279
+ tasks: TeamTaskState[],
280
+ status: "cancelled" | "failed",
281
+ reason: string,
282
+ filter?: (task: TeamTaskState) => boolean,
283
+ transform?: (task: TeamTaskState, terminalised: TeamTaskState) => TeamTaskState,
284
+ ): TeamTaskState[] {
285
+ const predicate = filter ?? ((task: TeamTaskState) => isNonTerminalTaskStatus(task.status));
286
+ return tasks.map((task) => {
287
+ if (!predicate(task)) return task;
288
+ const terminalised: TeamTaskState = { ...task, status, finishedAt: new Date().toISOString(), error: reason };
289
+ return transform ? transform(task, terminalised) : terminalised;
290
+ });
291
+ }
292
+
293
+ // 2.8: adaptive-plan parsing/repair/injection moved to src/runtime/goal-workflow/adaptive-plan.ts.
294
+ // Re-export the test-only helpers so existing test imports still resolve.
295
+
296
+ function retryPolicyFromConfig(config: CrewReliabilityConfig | undefined): RetryPolicy {
297
+ return { ...DEFAULT_RETRY_POLICY, ...(config?.retryPolicy ?? {}) };
298
+ }
299
+
300
+ /**
301
+ * #1 (assessment): decide whether the per-task retry path (executeWithRetry) is used.
302
+ * Defaults to TRUE (opt-out) so transient worker hangs (ChildTimeout) are retried
303
+ * automatically. Previously opt-in, which left the entire retry+recovery stack dormant.
304
+ * Exported for unit testing.
305
+ */
306
+ export function shouldUseRetry(reliability: CrewReliabilityConfig | undefined): boolean {
307
+ return reliability?.autoRetry !== false;
308
+ }
309
+
310
+ function failedTaskFrom(result: { tasks: TeamTaskState[] }, taskId: string): TeamTaskState | undefined {
311
+ return result.tasks.find((item) => item.id === taskId && item.status === "failed");
312
+ }
313
+
314
+ /**
315
+ * Check whether any task uses explicit `dependsOn` that would benefit from DAG-based
316
+ * execution planning. If so, build an execution plan and use `getDagReadyTasks`
317
+ * to augment the ready-set selection.
318
+ */
319
+ function dagReadyTaskIds(tasks: TeamTaskState[], completedIds: Set<string>): string[] | null {
320
+ const hasExplicitDeps = tasks.some((t) => t.dependsOn.length > 0);
321
+ if (!hasExplicitDeps) return null;
322
+ // FIX (goal-wrap runtime test): task.dependsOn stores STEP IDs (e.g. "execute"), not
323
+ // task IDs (e.g. "02_execute"). The DAG scheduler compares deps against completedIds
324
+ // (which are task IDs), so step-ID deps would never match → dependent tasks stuck blocked
325
+ // forever. Map step IDs -> task IDs first (mirror dependencySatisfied in
326
+ // task-graph-scheduler.ts which handles this via stepToTaskId). buildDagExecutionPlan +
327
+ // getDagReadyTasks then work on consistent task IDs.
328
+ const stepToTaskId = new Map<string, string>();
329
+ for (const t of tasks) {
330
+ if (t.stepId) stepToTaskId.set(t.stepId, t.id);
331
+ }
332
+ const nodes: TaskNode[] = tasks.map((t) => ({
333
+ id: t.id,
334
+ dependsOn: t.dependsOn.map((dep) => stepToTaskId.get(dep) ?? dep),
335
+ phase: t.adaptive?.phase ?? t.stepId,
336
+ }));
337
+ const plan = buildDagExecutionPlan(nodes);
338
+ if (plan.hasCycle) return null; // fall back to existing scheduler
339
+ return getDagReadyTasks(plan, completedIds);
340
+ }
341
+
342
+ /**
343
+ * CORE-4 extraction 3: select the dispatch batch for the current loop
344
+ * iteration.
345
+ *
346
+ * Computes the task-graph snapshot, DAG-ready tasks, workflow phase
347
+ * preconditions, batch concurrency, write-path-overlap serialization,
348
+ * coalesced-group logging, and streaming-dispatch slot allocation to
349
+ * determine which tasks are ready to dispatch this cycle.
350
+ *
351
+ * Returns:
352
+ * - `{ kind: "return", result }` when the run must block or abort
353
+ * (plan-approval pending with mutating tasks, or no ready task at all).
354
+ * - `{ kind: "dispatch", batch, ... }` when a batch is selected (may be
355
+ * empty when tasks are still in-flight — the caller proceeds to the
356
+ * wait phase with an empty dispatch set).
357
+ *
358
+ * The caller syncs `ctx.wfMachine` back after the call because this
359
+ * function may advance the workflow phase state machine.
360
+ *
361
+ * @param ctx The scheduler context; `ctx.wfMachine`, `ctx.tasks`, and
362
+ * `ctx.manifest` may be mutated in-place.
363
+ */
364
+ /**
365
+ * Pre-batch sweeps, shared by every scheduler tick (selectDispatchBatch) —
366
+ * exported separately so the WIRING (not just the sweep internals) is testable
367
+ * without driving a full team run (code review R7c).
368
+ *
369
+ * 1. WP-2/R2 (ADR-0 item 10): parked-ask deadline sweep — every tick, before
370
+ * batch selection (a requeued park must be dispatchable this tick).
371
+ * 2. T2/R4 (ADR-4 §4): re-plan reconciliation — queued tasks of items dropped
372
+ * by the current revision are cancelled, in-flight ones get a wrap-up
373
+ * advisory (soft cancel). Cheap-exits inside make this a no-op for runs
374
+ * without plan-linked tasks.
375
+ * Mutates ctx.manifest/ctx.tasks from the sweeps' disk-reloaded results.
376
+ */
377
+ export async function runSchedulerSweeps(ctx: { manifest: TeamRunManifest; tasks: TeamTaskState[] }): Promise<void> {
378
+ const sweep = await sweepExpiredWaitingTasks(ctx.manifest.cwd, ctx.manifest.runId);
379
+ if (sweep) {
380
+ ctx.manifest = sweep.manifest;
381
+ ctx.tasks = sweep.tasks;
382
+ }
383
+ const droppedSweep = sweepDroppedPlanItems(ctx.manifest, ctx.tasks);
384
+ if (droppedSweep) {
385
+ ctx.manifest = droppedSweep.manifest;
386
+ ctx.tasks = droppedSweep.tasks;
387
+ }
388
+ }
389
+
390
+ export async function selectDispatchBatch(ctx: SchedulerContext): Promise<SchedulerDecision> {
391
+ // WP-2/R2 (ADR-0 item 10): this tick owns parked-ask deadlines — every
392
+ // scheduler iteration sweeps tasks with an expired `task.waiting.deadline`
393
+ // BEFORE batch selection (a requeued park must be dispatchable this tick).
394
+ await runSchedulerSweeps(ctx);
395
+ const snapshot = taskGraphSnapshot(ctx.tasks, ctx.queueIndex);
396
+
397
+ // DAG-based execution plan: when tasks have explicit dependsOn, use the
398
+ // topological wave planner to determine ready tasks. Fall back to the
399
+ // existing task-graph-scheduler when no explicit deps exist (backward compat).
400
+ const completedIds = new Set(ctx.tasks.filter((t) => t.status === "completed" || t.status === "needs_attention").map((t) => t.id));
401
+ const dagReady = dagReadyTaskIds(ctx.tasks, completedIds);
402
+ const readyBeforeFilter = dagReady ?? snapshot.ready;
403
+
404
+ // Workflow phase precondition check (non-blocking: log warnings only).
405
+ if (ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length) {
406
+ const completedArtifacts = ctx.manifest.artifacts.filter((a) => a.kind === "result" || a.kind === "summary").map((a) => a.path);
407
+ const previousPhaseStatus =
408
+ ctx.wfMachine.currentPhaseIndex > 0
409
+ ? (ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex - 1]?.status ?? "pending")
410
+ : "completed";
411
+ const wfContext: PhaseGuardContext = {
412
+ completedArtifacts,
413
+ previousPhaseStatus,
414
+ taskResults: ctx.tasks
415
+ .filter((t) => t.status === "completed" || t.status === "needs_attention")
416
+ .map((t) => ({
417
+ taskId: t.id,
418
+ status: t.status,
419
+ outputPath: t.resultArtifact?.path,
420
+ })),
421
+ };
422
+ const preconditions = validatePhasePreconditions(ctx.wfMachine, wfContext);
423
+ if (!preconditions.ready) {
424
+ await appendEventAsync(ctx.manifest.eventsPath, {
425
+ type: "workflow.preconditions",
426
+ runId: ctx.manifest.runId,
427
+ message: `Workflow phase '${ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.name}' is missing inputs: ${preconditions.blocking.join(", ")}`,
428
+ data: {
429
+ phaseIndex: ctx.wfMachine.currentPhaseIndex,
430
+ phaseName: ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.name,
431
+ blocking: preconditions.blocking,
432
+ },
433
+ });
434
+ } else {
435
+ // Advance the machine past completed phases.
436
+ while (
437
+ ctx.wfMachine.currentPhaseIndex < ctx.wfMachine.phases.length &&
438
+ ctx.wfMachine.phases[ctx.wfMachine.currentPhaseIndex]?.status === "completed"
439
+ ) {
440
+ ctx.wfMachine = {
441
+ ...ctx.wfMachine,
442
+ currentPhaseIndex: ctx.wfMachine.currentPhaseIndex + 1,
443
+ };
444
+ }
445
+ }
446
+ }
447
+
448
+ // W5-4: by-id map once (was O(ready × tasks) via find-per-element).
449
+ const taskByIdReady = new Map(ctx.tasks.map((t) => [t.id, t] as const));
450
+ const readyRoles = readyBeforeFilter.map((taskId) => taskByIdReady.get(taskId)?.role).filter((role): role is string => Boolean(role));
451
+ const concurrency = resolveBatchConcurrency({
452
+ workflowName: ctx.workflow.name,
453
+ workflowMaxConcurrency: ctx.workflow.maxConcurrency,
454
+ teamMaxConcurrency: ctx.input.team.maxConcurrency,
455
+ limitMaxConcurrentWorkers: ctx.input.limits?.maxConcurrentWorkers,
456
+ allowUnboundedConcurrency: ctx.input.limits?.allowUnboundedConcurrency,
457
+ readyCount: readyBeforeFilter.length,
458
+ workspaceMode: ctx.manifest.workspaceMode,
459
+ readyRoles,
460
+ });
461
+
462
+ // Round 25 (M5): serialize on write-path overlap when opted in.
463
+ // Opt-in via limits.serializeOnPathOverlap; default off (= no behavior change).
464
+ // filterReadyByWriteOverlap returns the same array when enabled=false, so
465
+ // production runs pay nothing for the unused code path. When the flag is on,
466
+ // `serializedReady` MAY be a strict subset of `readyBeforeFilter` (conflicting tasks
467
+ // deferred to next cycle).
468
+ const serializedReady = filterReadyByWriteOverlap(
469
+ readyBeforeFilter,
470
+ ctx.tasks,
471
+ ctx.workflow,
472
+ concurrency.maxConcurrent,
473
+ ctx.input.limits?.serializeOnPathOverlap === true,
474
+ );
475
+
476
+ // Round 25 (M6): coalesce micro-tasks when opted in.
477
+ // Default off; when on, groups same-(role,cwd) tasks into coalesced groups
478
+ // (with write-path safety). In v0.9.17 first ship, we ONLY log the
479
+ // coalesced group count to the event stream (informational). Actual
480
+ // dispatching of one-multi-task worker instead of N workers is deferred
481
+ // to a follow-up — it's a non-trivial prompt-construction change that
482
+ // deserves its own PR. For now, every coalesced group => one info event.
483
+ const coalesceEnabled = ctx.workflow.coalesceMicroTasks === true;
484
+ if (coalesceEnabled) {
485
+ const coalescedGroups = planCoalescedGroups(serializedReady, ctx.tasks, ctx.workflow, true);
486
+ for (const group of coalescedGroups) {
487
+ if (group.tasks.length < 2) continue; // singletons are not interesting
488
+ await appendEventAsync(ctx.manifest.eventsPath, {
489
+ type: "task.coalesced",
490
+ runId: ctx.manifest.runId,
491
+ message: `Coalesced ${group.tasks.length} micro-tasks (role=${group.role}, cwd=${group.cwd})`,
492
+ data: {
493
+ groupId: group.id,
494
+ role: group.role,
495
+ cwd: group.cwd,
496
+ taskIds: group.tasks.map((task) => task.id),
497
+ },
498
+ });
499
+ }
500
+ }
501
+ if (concurrency.reason.includes(";unbounded:")) {
502
+ await appendEventAsync(ctx.manifest.eventsPath, {
503
+ type: "limits.unbounded",
504
+ runId: ctx.manifest.runId,
505
+ message: "Unbounded worker concurrency was explicitly enabled for this run.",
506
+ data: {
507
+ concurrencyReason: concurrency.reason,
508
+ maxConcurrent: concurrency.maxConcurrent,
509
+ },
510
+ });
511
+ }
512
+ // ── OPT-01 streaming dispatch: exclude tasks already in-flight, limit
513
+ // new dispatches to available concurrency slots. ──
514
+ const inFlightTaskIds = new Set<string>();
515
+ for (const pendingUnit of ctx.pendingUnits.values()) {
516
+ for (const taskId of pendingUnit.taskIds) inFlightTaskIds.add(taskId);
517
+ }
518
+ const slotsAvailable = Math.max(0, concurrency.maxConcurrent - ctx.pendingUnits.size);
519
+ // Review R3: the dispatch gate reads plan-record-first (ADR-4 §8) — in the
520
+ // crash window between the record write and the manifest save, the record's
521
+ // decision wins (resolve in favor of user intent).
522
+ const approvalPending = isPlanApprovalPendingEffective(ctx.manifest);
523
+ const dispatchableReady = serializedReady.filter((id) => !inFlightTaskIds.has(id));
524
+ const readyIds = approvalPending ? dispatchableReady : dispatchableReady.slice(0, slotsAvailable);
525
+ const taskByIdDispatch = new Map(ctx.tasks.map((t) => [t.id, t] as const));
526
+ const candidateBatch = readyIds.map((id) => taskByIdDispatch.get(id)).filter((task): task is TeamTaskState => Boolean(task));
527
+ const readyBatch = approvalPending ? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable) : candidateBatch;
528
+ if (readyBatch.length === 0) {
529
+ if (ctx.pendingUnits.size > 0) {
530
+ // Tasks are in-flight — skip dispatch and proceed to wait phase.
531
+ // (No return; code falls through to the dispatch section which is
532
+ // a no-op with an empty readyBatch, then reaches the wait phase.)
533
+ } else if (approvalPending && candidateBatch.some(isMutatingTask)) {
534
+ await saveRunTasksAsync(ctx.manifest, ctx.tasks);
535
+ saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
536
+ ctx.manifest = updateRunStatus(ctx.manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
537
+ return { kind: "return", result: { manifest: ctx.manifest, tasks: ctx.tasks } };
538
+ } else {
539
+ ctx.tasks = markBlocked(ctx.tasks, "No ready queued task; dependency graph may be invalid.");
540
+ await saveRunTasksAsync(ctx.manifest, ctx.tasks);
541
+ saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
542
+ ctx.manifest = updateRunStatus(ctx.manifest, "blocked", "No ready queued task.");
543
+ return { kind: "return", result: { manifest: ctx.manifest, tasks: ctx.tasks } };
544
+ }
545
+ }
546
+
547
+ return { kind: "dispatch", batch: readyBatch, concurrency, snapshot, approvalPending, coalesceEnabled };
548
+ }
549
+
550
+ export type DispatchBatchDecision = Extract<SchedulerDecision, { kind: "dispatch" }>;
551
+
552
+ /**
553
+ * CORE-4 extraction 4: execute the dispatch batch selected by
554
+ * selectDispatchBatch.
555
+ *
556
+ * Runs before_task_start hooks (skipping blocked tasks), builds coalesced
557
+ * dispatch units, pre-warms the stable-prefix cache for unique cwds, and
558
+ * dispatches each unit into ctx.pendingUnits as a fire-and-forget promise
559
+ * (wrapped in executeWithRetry on the singleton path). The function is a
560
+ * verbatim lift of the inline dispatch block; it does not return a
561
+ * SchedulerDecision (void — it only populates ctx.pendingUnits).
562
+ *
563
+ * Reads ctx.manifest/tasks/workflow/input + runController.signal. Mutates
564
+ * ctx.pendingUnits (add), ctx.tasks (hook skips), ctx.manifest (hook
565
+ * status). The mutable manifest/tasks are accessed via ctx.* (not captured
566
+ * locals) so that async retry callbacks observe the caller's re-synced
567
+ * values, matching the original closure semantics.
568
+ *
569
+ * @param ctx The scheduler context.
570
+ * @param decision The dispatch decision from selectDispatchBatch.
571
+ */
572
+ export async function dispatchBatch(ctx: SchedulerContext, decision: DispatchBatchDecision): Promise<void> {
573
+ const { batch: readyBatch, concurrency, snapshot, approvalPending, coalesceEnabled } = decision;
574
+ // Immutable context fields captured once; manifest/tasks are accessed via
575
+ // ctx.* because they may be re-synced by the caller between dispatch and
576
+ // promise resolution (retry callbacks fire asynchronously).
577
+ const { workflow, input, runtimeKind, runController } = ctx;
578
+
579
+ // 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
580
+ void appendEventBuffered(ctx.manifest.eventsPath, {
581
+ type: "task.progress",
582
+ runId: ctx.manifest.runId,
583
+ message: `Starting ready batch with ${readyBatch.length} task(s).`,
584
+ data: {
585
+ taskIds: readyBatch.map((task) => task.id),
586
+ readyCount: snapshot.ready.length,
587
+ blockedCount: snapshot.blocked.length,
588
+ runningCount: snapshot.running.length,
589
+ doneCount: snapshot.done.length,
590
+ selectedCount: readyBatch.length,
591
+ maxConcurrent: concurrency.maxConcurrent,
592
+ defaultConcurrency: concurrency.defaultConcurrency,
593
+ concurrencyReason: approvalPending ? `${concurrency.reason};plan-approval-read-only` : concurrency.reason,
594
+ },
595
+ });
596
+ // Execute before_task_start hooks for the batch — P1-10: run hooks in
597
+ // parallel (each may be a subprocess), then apply skip mutations in order.
598
+ const beforeTaskStartReports = await Promise.all(
599
+ readyBatch.map((task) =>
600
+ executeHook("before_task_start", {
601
+ runId: ctx.manifest.runId,
602
+ taskId: task.id,
603
+ cwd: ctx.manifest.cwd,
604
+ }).then((taskReport) => ({ task, taskReport })),
605
+ ),
606
+ );
607
+ for (const { task, taskReport } of beforeTaskStartReports) {
608
+ appendHookEvent(ctx.manifest, taskReport);
609
+ if (taskReport.outcome === "block") {
610
+ ctx.tasks = ctx.tasks.map((t) =>
611
+ t.id === task.id
612
+ ? {
613
+ ...t,
614
+ status: "skipped" as const,
615
+ error: taskReport.reason ?? "before_task_start hook blocked execution.",
616
+ }
617
+ : t,
618
+ );
619
+ ctx.manifest = updateRunStatus(ctx.manifest, ctx.manifest.status, `Task '${task.id}' blocked by hook.`);
620
+ }
621
+ }
622
+ // W5-4: by-id map (was O(readyBatch × tasks) via find-per-element).
623
+ const ctxTaskById = new Map(ctx.tasks.map((t) => [t.id, t] as const));
624
+ const batchTasks = readyBatch.filter((task) => {
625
+ const t = ctxTaskById.get(task.id);
626
+ return t !== undefined && t.status !== "skipped";
627
+ });
628
+ if (batchTasks.length > 1) {
629
+ await appendEventAsync(ctx.manifest.eventsPath, {
630
+ type: "task.parallel_start",
631
+ runId: ctx.manifest.runId,
632
+ message: `Launching ${batchTasks.length} tasks in PARALLEL (concurrency=${concurrency.selectedCount}): ${batchTasks.map((t) => `${t.role}(${t.id})`).join(", ")}`,
633
+ data: {
634
+ taskIds: batchTasks.map((t) => t.id),
635
+ roles: batchTasks.map((t) => t.role),
636
+ concurrency: concurrency.selectedCount,
637
+ },
638
+ });
639
+ }
640
+
641
+ // M6 real dispatch: when coalesceMicroTasks is enabled, batch the
642
+ // ready tasks into dispatch units. Multi-task groups are dispatched
643
+ // as one worker (single cold-start) instead of N. Singletons fall
644
+ // through to per-task dispatch.
645
+ const coalescedGroups = planCoalescedGroups(
646
+ batchTasks.map((t) => t.id),
647
+ ctx.tasks,
648
+ workflow,
649
+ coalesceEnabled,
650
+ );
651
+ const dispatchUnits = buildDispatchUnits(
652
+ batchTasks.map((t) => t.id),
653
+ coalescedGroups,
654
+ );
655
+
656
+ // NEW-M1: Pre-warm stable prefix cache for one representative task
657
+ // per unique cwd. Parallel siblings with the same cwd/step reuse
658
+ // the cached workspace tree, file retrieval, and knowledge fragment
659
+ // instead of recomputing them independently (~200-800ms per batch).
660
+ if (batchTasks.length > 1) {
661
+ const seenCwds = new Set<string>();
662
+ await Promise.all(
663
+ batchTasks
664
+ .filter((task) => {
665
+ if (seenCwds.has(task.cwd)) return false;
666
+ seenCwds.add(task.cwd);
667
+ return true;
668
+ })
669
+ .map((task) => {
670
+ const step = findStep(workflow, task);
671
+ return computeStablePrefixComponents(ctx.manifest, step, task);
672
+ }),
673
+ );
674
+ }
675
+
676
+ // ── OPT-01 streaming dispatch: dispatch each unit into ctx.pendingUnits
677
+ // instead of awaiting the entire batch via mapConcurrent. Each unit's
678
+ // promise is stored so we can Promise.race on the next iteration. ──
679
+ const dispatchUnit = async (unit: DispatchUnit): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> => {
680
+ // M6 real dispatch path: single worker for N tasks.
681
+ if (unit.kind === "group") {
682
+ const groupTasks = unit.group.tasks;
683
+ const firstTask = groupTasks[0]!;
684
+ const step = findStep(workflow, firstTask);
685
+ const agent = findAgent(input.agents, firstTask);
686
+ const teamRole = input.team.roles.find((role) => role.name === firstTask.role);
687
+ const perTaskRuntime = resolveTaskRuntimeKind(runtimeKind, firstTask.role, input.runtimeConfig?.isolationPolicy);
688
+ return runCoalescedTaskGroup({
689
+ manifest: ctx.manifest,
690
+ tasks: ctx.tasks,
691
+ groupTasks,
692
+ step,
693
+ agent,
694
+ signal: runController.signal,
695
+ executeWorkers: input.executeWorkers,
696
+ runtimeKind,
697
+ workspaceId: input.workspaceId,
698
+ onJsonEvent: input.onJsonEvent,
699
+ runtimeConfig: input.runtimeConfig,
700
+ reliability: input.reliability,
701
+ teamRole,
702
+ perTaskRuntime,
703
+ });
704
+ }
705
+ // Singleton path: original per-task dispatch.
706
+ const task = batchTasks.find((t) => t.id === unit.taskId)!;
707
+ const step = findStep(workflow, task);
708
+ const agent = findAgent(input.agents, task);
709
+ const teamRole = input.team.roles.find((role) => role.name === task.role);
710
+ const perTaskRuntime = resolveTaskRuntimeKind(runtimeKind, task.role, input.runtimeConfig?.isolationPolicy);
711
+ // CORE-3: compute retry policy + spawn budget ONCE per dispatch unit.
712
+ // The spawnBudget object is shared (by reference) across every
713
+ // runTeamTask call within executeWithRetry via baseInput spread,
714
+ // so the counter accumulates across retry attempts × model fallbacks.
715
+ const policy = retryPolicyFromConfig(input.reliability);
716
+ const spawnBudget: SpawnBudget = { count: 0, max: policy.maxTotalSpawns ?? 0 };
717
+ const baseInput = {
718
+ manifest: ctx.manifest,
719
+ tasks: ctx.tasks,
720
+ task,
721
+ step,
722
+ agent,
723
+ signal: runController.signal,
724
+ executeWorkers: input.executeWorkers,
725
+ runtimeKind: runtimeKind,
726
+ taskRuntimeOverride: perTaskRuntime !== runtimeKind ? perTaskRuntime : undefined,
727
+ runtimeConfig: input.runtimeConfig,
728
+ parentContext: input.parentContext,
729
+ parentModel: input.parentModel,
730
+ modelRegistry: input.modelRegistry,
731
+ modelOverride: input.modelOverride,
732
+ teamRoleModel: teamRole?.model,
733
+ teamRoleThinking: teamRole?.thinking,
734
+ teamRoleFallbackModels: teamRole?.fallbackModels,
735
+ teamRoleSkills: teamRole?.skills,
736
+ skillOverride: input.skillOverride,
737
+ limits: input.limits,
738
+ onJsonEvent: input.onJsonEvent,
739
+ workspaceId: input.workspaceId,
740
+ spawnBudget,
741
+ // R10-1 residual: same per-run cache instance as the closeout — one
742
+ // object reference spread into EVERY runTeamTask call (initial + every
743
+ // retry attempt via `...baseInput`), so dep-context reads and closeout
744
+ // aggregation share memoized artifacts.
745
+ resultReadCache: ctx.resultReadCache,
746
+ };
747
+ // #1 (assessment): autoRetry now defaults ON (opt-out via reliability.autoRetry=false).
748
+ // The dominant v0.9.13 failure was ChildTimeout ("worker became unresponsive") with
749
+ // ZERO retries because this gate was opt-in. isRetryable() defaults to true when
750
+ // retryableErrors is empty, so transient hangs now retry up to maxAttempts (3) with
751
+ // exponential backoff. Set reliability.autoRetry=false to restore old single-shot behavior.
752
+ if (!shouldUseRetry(input.reliability))
753
+ return withCorrelation(childCorrelation(ctx.manifest.runId, task.id), () => runTeamTask(baseInput));
754
+ let lastFailed: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
755
+ let lastAttemptId: string | undefined;
756
+ const attemptsSoFar: TaskAttemptState[] = [...(task.attempts ?? [])];
757
+ try {
758
+ return await executeWithRetry(
759
+ async (attempt, info) => {
760
+ const startedAt = new Date().toISOString();
761
+ const inFlightAttempts: TaskAttemptState[] = [...attemptsSoFar, { attemptId: info.attemptId, startedAt }];
762
+ input.metricRegistry?.counter("crew.task.retry_attempt_total", "Retry attempts by run and task").inc({
763
+ runId: ctx.manifest.runId,
764
+ taskId: task.id,
765
+ });
766
+ // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
767
+ const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
768
+ const freshManifest = fresh?.manifest ?? ctx.manifest;
769
+ const freshTasks = fresh?.tasks ?? ctx.tasks;
770
+ const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
771
+ if (freshTask.status !== "queued" && freshTask.status !== "running")
772
+ return {
773
+ manifest: freshManifest,
774
+ tasks: freshTasks,
775
+ };
776
+ const taskWithAttempt: TeamTaskState = {
777
+ ...freshTask,
778
+ attempts: inFlightAttempts,
779
+ };
780
+ const result = await withCorrelation(childCorrelation(freshManifest.runId, task.id), () =>
781
+ runTeamTask({
782
+ ...baseInput,
783
+ manifest: freshManifest,
784
+ tasks: freshTasks,
785
+ task: taskWithAttempt,
786
+ }),
787
+ );
788
+ const failed = failedTaskFrom(result, task.id);
789
+ const endedAt = new Date().toISOString();
790
+ const finishedAttempt: TaskAttemptState = {
791
+ attemptId: info.attemptId,
792
+ startedAt,
793
+ endedAt,
794
+ ...(failed?.error ? { error: failed.error } : {}),
795
+ };
796
+ attemptsSoFar.push(finishedAttempt);
797
+ const withAttempt = result.tasks.map((item) =>
798
+ item.id === task.id ? { ...item, attempts: [...attemptsSoFar] } : item,
799
+ );
800
+ const enriched = {
801
+ manifest: result.manifest,
802
+ tasks: withAttempt,
803
+ };
804
+ if (failed) {
805
+ lastFailed = enriched;
806
+ throw new CrewError(ErrorCode.TaskNotFound, failed.error ?? `Task ${task.id} failed.`).withContext(
807
+ `retry evaluation (run=${ctx.manifest.runId})`,
808
+ );
809
+ }
810
+ input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
811
+ {
812
+ runId: ctx.manifest.runId,
813
+ team: input.team.name,
814
+ },
815
+ Math.max(0, attempt - 1),
816
+ );
817
+ return enriched;
818
+ },
819
+ policy,
820
+ {
821
+ signal: runController.signal,
822
+ attemptId: (attempt) => `${ctx.manifest.runId}:${task.id}:attempt-${attempt}`,
823
+ onAttemptFailed: (attempt, error, delayMs, info) => {
824
+ lastAttemptId = info.attemptId;
825
+ appendEventAsync(ctx.manifest.eventsPath, {
826
+ type: "crew.task.retry_attempt",
827
+ runId: ctx.manifest.runId,
828
+ taskId: task.id,
829
+ message: error.message,
830
+ data: {
831
+ attempt,
832
+ attemptId: info.attemptId,
833
+ delayMs,
834
+ },
835
+ metadata: { attemptId: info.attemptId },
836
+ }).catch((error) => logInternalError("team-runner.retry-attempt", error, `taskId=${task.id}`));
837
+ input.metricRegistry?.histogram("crew.task.retry_delay_ms", "Retry backoff delay, milliseconds").observe(
838
+ {
839
+ runId: ctx.manifest.runId,
840
+ taskId: task.id,
841
+ },
842
+ delayMs,
843
+ );
844
+ },
845
+ onRetryGivenUp: (attempts, error, info) => {
846
+ lastAttemptId = info.attemptId;
847
+ appendDeadletter(ctx.manifest, {
848
+ runId: ctx.manifest.runId,
849
+ taskId: task.id,
850
+ reason: "max-retries",
851
+ attempts,
852
+ attemptId: info.attemptId,
853
+ lastError: error.message,
854
+ timestamp: new Date().toISOString(),
855
+ });
856
+ input.metricRegistry
857
+ ?.counter("crew.task.deadletter_total", "Deadletter triggers by reason")
858
+ .inc({ reason: "max-retries" });
859
+ input.metricRegistry?.histogram("crew.task.retry_count", "Retries per task", [0, 1, 2, 3, 5, 10]).observe(
860
+ {
861
+ runId: ctx.manifest.runId,
862
+ team: input.team.name,
863
+ },
864
+ Math.max(0, attempts - 1),
865
+ );
866
+ },
867
+ },
868
+ );
869
+ } catch (retryError) {
870
+ if (retryError instanceof CrewCancellationError || input.signal?.aborted) {
871
+ const reason = retryError instanceof CrewCancellationError ? retryError.reason : cancellationReasonFromSignal(input.signal);
872
+ // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
873
+ const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
874
+ const freshManifest = fresh?.manifest ?? ctx.manifest;
875
+ const freshTasks = fresh?.tasks ?? ctx.tasks;
876
+ const cancelledTasks = cancelNonTerminalTasks(
877
+ freshTasks,
878
+ "cancelled",
879
+ `${reason.message} (${reason.code})`,
880
+ (item) => item.id === task.id && (item.status === "queued" || item.status === "running"),
881
+ );
882
+ appendEventAsync(freshManifest.eventsPath, {
883
+ type: "task.cancelled",
884
+ runId: freshManifest.runId,
885
+ taskId: task.id,
886
+ message: reason.message,
887
+ data: { reason, phase: "retry" },
888
+ metadata: lastAttemptId ? { attemptId: lastAttemptId } : undefined,
889
+ }).catch((error) => logInternalError("team-runner.cancelled", error, `taskId=${task.id}`));
890
+ return {
891
+ manifest: updateRunStatus(freshManifest, "cancelled", reason.message),
892
+ tasks: cancelledTasks,
893
+ };
894
+ }
895
+ if (lastFailed) return lastFailed;
896
+ // NOTE: no withRunLock — best-effort only; concurrent writes may cause inconsistency
897
+ const fresh = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
898
+ const freshManifest = fresh?.manifest ?? ctx.manifest;
899
+ const freshTasks = fresh?.tasks ?? ctx.tasks;
900
+ const freshTask = freshTasks.find((item) => item.id === task.id) ?? task;
901
+ if (freshTask.status !== "queued" && freshTask.status !== "running") return { manifest: freshManifest, tasks: freshTasks };
902
+ return withCorrelation(childCorrelation(freshManifest.runId, task.id), () =>
903
+ runTeamTask({
904
+ ...baseInput,
905
+ manifest: freshManifest,
906
+ tasks: freshTasks,
907
+ task: freshTask,
908
+ }),
909
+ );
910
+ }
911
+ };
912
+ // ── OPT-01 streaming dispatch: dispatch units into ctx.pendingUnits ──
913
+ for (const unit of dispatchUnits) {
914
+ const unitKey = unit.kind === "singleton" ? unit.taskId : unit.group.id;
915
+ const unitTaskIds = unit.kind === "singleton" ? [unit.taskId] : unit.group.tasks.map((t) => t.id);
916
+ // RT-12: create the wrapper promise ONCE at dispatch time so
917
+ // mergeUnitResult can Promise.race on pre-existing wrappers instead
918
+ // of allocating new async closures every loop iteration.
919
+ const rawPromise = dispatchUnit(unit);
920
+ const wrapped: Promise<SettledUnit> = (async () => {
921
+ try {
922
+ const result = await rawPromise;
923
+ return {
924
+ unitKey,
925
+ result: result as { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined,
926
+ error: undefined as Error | undefined,
927
+ };
928
+ } catch (error) {
929
+ return { unitKey, result: undefined, error: error instanceof Error ? error : new Error(String(error)) };
930
+ }
931
+ })();
932
+ ctx.pendingUnits.set(unitKey, {
933
+ taskIds: unitTaskIds,
934
+ promise: rawPromise,
935
+ wrapped,
936
+ });
937
+ // RT-NEW-2 race fix: record ever-dispatched task ids so terminaliseRunWithDrain
938
+ // cancels (not skips) tasks whose unit settled + left pendingUnits before
939
+ // the abort fired but whose task status isn't terminal yet.
940
+ for (const id of unitTaskIds) ctx.dispatchedTaskIds.add(id);
941
+ }
942
+ }
943
+
944
+ /** @internal 1.9(b) test export — exercise selectDispatchBatch directly. */
945
+ export const __test__selectDispatchBatch = selectDispatchBatch;