opencastle 0.26.1 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +7 -1
  2. package/bin/cli.mjs +10 -0
  3. package/dist/cli/agents.d.ts +3 -0
  4. package/dist/cli/agents.d.ts.map +1 -0
  5. package/dist/cli/agents.js +161 -0
  6. package/dist/cli/agents.js.map +1 -0
  7. package/dist/cli/baselines.d.ts +3 -0
  8. package/dist/cli/baselines.d.ts.map +1 -0
  9. package/dist/cli/baselines.js +128 -0
  10. package/dist/cli/baselines.js.map +1 -0
  11. package/dist/cli/convoy/engine.d.ts +68 -2
  12. package/dist/cli/convoy/engine.d.ts.map +1 -1
  13. package/dist/cli/convoy/engine.js +2102 -26
  14. package/dist/cli/convoy/engine.js.map +1 -1
  15. package/dist/cli/convoy/engine.test.js +1572 -70
  16. package/dist/cli/convoy/engine.test.js.map +1 -1
  17. package/dist/cli/convoy/events.d.ts +4 -1
  18. package/dist/cli/convoy/events.d.ts.map +1 -1
  19. package/dist/cli/convoy/events.js +74 -13
  20. package/dist/cli/convoy/events.js.map +1 -1
  21. package/dist/cli/convoy/events.test.js +154 -27
  22. package/dist/cli/convoy/events.test.js.map +1 -1
  23. package/dist/cli/convoy/expertise.d.ts +16 -0
  24. package/dist/cli/convoy/expertise.d.ts.map +1 -0
  25. package/dist/cli/convoy/expertise.js +121 -0
  26. package/dist/cli/convoy/expertise.js.map +1 -0
  27. package/dist/cli/convoy/expertise.test.d.ts +2 -0
  28. package/dist/cli/convoy/expertise.test.d.ts.map +1 -0
  29. package/dist/cli/convoy/expertise.test.js +96 -0
  30. package/dist/cli/convoy/expertise.test.js.map +1 -0
  31. package/dist/cli/convoy/export.test.js +1 -0
  32. package/dist/cli/convoy/export.test.js.map +1 -1
  33. package/dist/cli/convoy/formula.d.ts +19 -0
  34. package/dist/cli/convoy/formula.d.ts.map +1 -0
  35. package/dist/cli/convoy/formula.js +142 -0
  36. package/dist/cli/convoy/formula.js.map +1 -0
  37. package/dist/cli/convoy/formula.test.d.ts +2 -0
  38. package/dist/cli/convoy/formula.test.d.ts.map +1 -0
  39. package/dist/cli/convoy/formula.test.js +342 -0
  40. package/dist/cli/convoy/formula.test.js.map +1 -0
  41. package/dist/cli/convoy/gates.d.ts +128 -0
  42. package/dist/cli/convoy/gates.d.ts.map +1 -0
  43. package/dist/cli/convoy/gates.js +606 -0
  44. package/dist/cli/convoy/gates.js.map +1 -0
  45. package/dist/cli/convoy/gates.test.d.ts +2 -0
  46. package/dist/cli/convoy/gates.test.d.ts.map +1 -0
  47. package/dist/cli/convoy/gates.test.js +976 -0
  48. package/dist/cli/convoy/gates.test.js.map +1 -0
  49. package/dist/cli/convoy/health.d.ts +11 -0
  50. package/dist/cli/convoy/health.d.ts.map +1 -1
  51. package/dist/cli/convoy/health.js +54 -0
  52. package/dist/cli/convoy/health.js.map +1 -1
  53. package/dist/cli/convoy/health.test.js +56 -1
  54. package/dist/cli/convoy/health.test.js.map +1 -1
  55. package/dist/cli/convoy/issues.d.ts +8 -0
  56. package/dist/cli/convoy/issues.d.ts.map +1 -0
  57. package/dist/cli/convoy/issues.js +98 -0
  58. package/dist/cli/convoy/issues.js.map +1 -0
  59. package/dist/cli/convoy/issues.test.d.ts +2 -0
  60. package/dist/cli/convoy/issues.test.d.ts.map +1 -0
  61. package/dist/cli/convoy/issues.test.js +107 -0
  62. package/dist/cli/convoy/issues.test.js.map +1 -0
  63. package/dist/cli/convoy/knowledge.d.ts +5 -0
  64. package/dist/cli/convoy/knowledge.d.ts.map +1 -0
  65. package/dist/cli/convoy/knowledge.js +116 -0
  66. package/dist/cli/convoy/knowledge.js.map +1 -0
  67. package/dist/cli/convoy/knowledge.test.d.ts +2 -0
  68. package/dist/cli/convoy/knowledge.test.d.ts.map +1 -0
  69. package/dist/cli/convoy/knowledge.test.js +87 -0
  70. package/dist/cli/convoy/knowledge.test.js.map +1 -0
  71. package/dist/cli/convoy/lessons.d.ts +17 -0
  72. package/dist/cli/convoy/lessons.d.ts.map +1 -0
  73. package/dist/cli/convoy/lessons.js +149 -0
  74. package/dist/cli/convoy/lessons.js.map +1 -0
  75. package/dist/cli/convoy/lessons.test.d.ts +2 -0
  76. package/dist/cli/convoy/lessons.test.d.ts.map +1 -0
  77. package/dist/cli/convoy/lessons.test.js +135 -0
  78. package/dist/cli/convoy/lessons.test.js.map +1 -0
  79. package/dist/cli/convoy/lock.d.ts +13 -0
  80. package/dist/cli/convoy/lock.d.ts.map +1 -0
  81. package/dist/cli/convoy/lock.js +88 -0
  82. package/dist/cli/convoy/lock.js.map +1 -0
  83. package/dist/cli/convoy/lock.test.d.ts +2 -0
  84. package/dist/cli/convoy/lock.test.d.ts.map +1 -0
  85. package/dist/cli/convoy/lock.test.js +136 -0
  86. package/dist/cli/convoy/lock.test.js.map +1 -0
  87. package/dist/cli/convoy/merge.d.ts +4 -0
  88. package/dist/cli/convoy/merge.d.ts.map +1 -1
  89. package/dist/cli/convoy/merge.js +18 -1
  90. package/dist/cli/convoy/merge.js.map +1 -1
  91. package/dist/cli/convoy/merge.test.js +6 -7
  92. package/dist/cli/convoy/merge.test.js.map +1 -1
  93. package/dist/cli/convoy/partition.d.ts +51 -0
  94. package/dist/cli/convoy/partition.d.ts.map +1 -0
  95. package/dist/cli/convoy/partition.js +186 -0
  96. package/dist/cli/convoy/partition.js.map +1 -0
  97. package/dist/cli/convoy/partition.test.d.ts +2 -0
  98. package/dist/cli/convoy/partition.test.d.ts.map +1 -0
  99. package/dist/cli/convoy/partition.test.js +315 -0
  100. package/dist/cli/convoy/partition.test.js.map +1 -0
  101. package/dist/cli/convoy/pipeline.test.js +6 -0
  102. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  103. package/dist/cli/convoy/store.d.ts +47 -5
  104. package/dist/cli/convoy/store.d.ts.map +1 -1
  105. package/dist/cli/convoy/store.js +525 -19
  106. package/dist/cli/convoy/store.js.map +1 -1
  107. package/dist/cli/convoy/store.test.js +1345 -12
  108. package/dist/cli/convoy/store.test.js.map +1 -1
  109. package/dist/cli/convoy/types.d.ts +156 -2
  110. package/dist/cli/convoy/types.d.ts.map +1 -1
  111. package/dist/cli/destroy.d.ts +3 -0
  112. package/dist/cli/destroy.d.ts.map +1 -0
  113. package/dist/cli/destroy.js +69 -0
  114. package/dist/cli/destroy.js.map +1 -0
  115. package/dist/cli/destroy.test.d.ts +2 -0
  116. package/dist/cli/destroy.test.d.ts.map +1 -0
  117. package/dist/cli/destroy.test.js +116 -0
  118. package/dist/cli/destroy.test.js.map +1 -0
  119. package/dist/cli/gitignore.d.ts +9 -0
  120. package/dist/cli/gitignore.d.ts.map +1 -1
  121. package/dist/cli/gitignore.js +29 -0
  122. package/dist/cli/gitignore.js.map +1 -1
  123. package/dist/cli/plan.d.ts +3 -0
  124. package/dist/cli/plan.d.ts.map +1 -0
  125. package/dist/cli/plan.js +288 -0
  126. package/dist/cli/plan.js.map +1 -0
  127. package/dist/cli/run/adapters/claude.d.ts +2 -0
  128. package/dist/cli/run/adapters/claude.d.ts.map +1 -1
  129. package/dist/cli/run/adapters/claude.js +89 -49
  130. package/dist/cli/run/adapters/claude.js.map +1 -1
  131. package/dist/cli/run/adapters/claude.test.d.ts +2 -0
  132. package/dist/cli/run/adapters/claude.test.d.ts.map +1 -0
  133. package/dist/cli/run/adapters/claude.test.js +205 -0
  134. package/dist/cli/run/adapters/claude.test.js.map +1 -0
  135. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  136. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  137. package/dist/cli/run/adapters/copilot.js +84 -46
  138. package/dist/cli/run/adapters/copilot.js.map +1 -1
  139. package/dist/cli/run/adapters/copilot.test.d.ts +2 -0
  140. package/dist/cli/run/adapters/copilot.test.d.ts.map +1 -0
  141. package/dist/cli/run/adapters/copilot.test.js +195 -0
  142. package/dist/cli/run/adapters/copilot.test.js.map +1 -0
  143. package/dist/cli/run/adapters/cursor.d.ts +1 -0
  144. package/dist/cli/run/adapters/cursor.d.ts.map +1 -1
  145. package/dist/cli/run/adapters/cursor.js +83 -47
  146. package/dist/cli/run/adapters/cursor.js.map +1 -1
  147. package/dist/cli/run/adapters/cursor.test.d.ts +2 -0
  148. package/dist/cli/run/adapters/cursor.test.d.ts.map +1 -0
  149. package/dist/cli/run/adapters/cursor.test.js +129 -0
  150. package/dist/cli/run/adapters/cursor.test.js.map +1 -0
  151. package/dist/cli/run/adapters/opencode.d.ts +1 -0
  152. package/dist/cli/run/adapters/opencode.d.ts.map +1 -1
  153. package/dist/cli/run/adapters/opencode.js +81 -47
  154. package/dist/cli/run/adapters/opencode.js.map +1 -1
  155. package/dist/cli/run/adapters/opencode.test.d.ts +2 -0
  156. package/dist/cli/run/adapters/opencode.test.d.ts.map +1 -0
  157. package/dist/cli/run/adapters/opencode.test.js +119 -0
  158. package/dist/cli/run/adapters/opencode.test.js.map +1 -0
  159. package/dist/cli/run/executor.js +1 -1
  160. package/dist/cli/run/executor.js.map +1 -1
  161. package/dist/cli/run/schema.d.ts.map +1 -1
  162. package/dist/cli/run/schema.js +245 -4
  163. package/dist/cli/run/schema.js.map +1 -1
  164. package/dist/cli/run/schema.test.js +669 -0
  165. package/dist/cli/run/schema.test.js.map +1 -1
  166. package/dist/cli/run.d.ts.map +1 -1
  167. package/dist/cli/run.js +362 -22
  168. package/dist/cli/run.js.map +1 -1
  169. package/dist/cli/types.d.ts +85 -2
  170. package/dist/cli/types.d.ts.map +1 -1
  171. package/dist/cli/types.js.map +1 -1
  172. package/dist/cli/watch.d.ts +15 -0
  173. package/dist/cli/watch.d.ts.map +1 -0
  174. package/dist/cli/watch.js +279 -0
  175. package/dist/cli/watch.js.map +1 -0
  176. package/package.json +1 -1
  177. package/src/cli/agents.ts +177 -0
  178. package/src/cli/baselines.ts +143 -0
  179. package/src/cli/convoy/engine.test.ts +1839 -70
  180. package/src/cli/convoy/engine.ts +2417 -38
  181. package/src/cli/convoy/events.test.ts +179 -38
  182. package/src/cli/convoy/events.ts +88 -16
  183. package/src/cli/convoy/expertise.test.ts +128 -0
  184. package/src/cli/convoy/expertise.ts +163 -0
  185. package/src/cli/convoy/export.test.ts +1 -0
  186. package/src/cli/convoy/formula.test.ts +405 -0
  187. package/src/cli/convoy/formula.ts +174 -0
  188. package/src/cli/convoy/gates.test.ts +1169 -0
  189. package/src/cli/convoy/gates.ts +774 -0
  190. package/src/cli/convoy/health.test.ts +64 -2
  191. package/src/cli/convoy/health.ts +80 -2
  192. package/src/cli/convoy/issues.test.ts +143 -0
  193. package/src/cli/convoy/issues.ts +136 -0
  194. package/src/cli/convoy/knowledge.test.ts +101 -0
  195. package/src/cli/convoy/knowledge.ts +132 -0
  196. package/src/cli/convoy/lessons.test.ts +188 -0
  197. package/src/cli/convoy/lessons.ts +164 -0
  198. package/src/cli/convoy/lock.test.ts +181 -0
  199. package/src/cli/convoy/lock.ts +103 -0
  200. package/src/cli/convoy/merge.test.ts +6 -7
  201. package/src/cli/convoy/merge.ts +19 -1
  202. package/src/cli/convoy/partition.test.ts +423 -0
  203. package/src/cli/convoy/partition.ts +232 -0
  204. package/src/cli/convoy/pipeline.test.ts +6 -0
  205. package/src/cli/convoy/store.test.ts +1512 -14
  206. package/src/cli/convoy/store.ts +676 -30
  207. package/src/cli/convoy/types.ts +170 -1
  208. package/src/cli/destroy.test.ts +141 -0
  209. package/src/cli/destroy.ts +88 -0
  210. package/src/cli/gitignore.ts +36 -0
  211. package/src/cli/plan.ts +316 -0
  212. package/src/cli/run/adapters/claude.test.ts +234 -0
  213. package/src/cli/run/adapters/claude.ts +45 -5
  214. package/src/cli/run/adapters/copilot.test.ts +224 -0
  215. package/src/cli/run/adapters/copilot.ts +34 -4
  216. package/src/cli/run/adapters/cursor.test.ts +144 -0
  217. package/src/cli/run/adapters/cursor.ts +33 -2
  218. package/src/cli/run/adapters/opencode.test.ts +135 -0
  219. package/src/cli/run/adapters/opencode.ts +30 -2
  220. package/src/cli/run/executor.ts +1 -1
  221. package/src/cli/run/schema.test.ts +758 -0
  222. package/src/cli/run/schema.ts +300 -25
  223. package/src/cli/run.ts +341 -21
  224. package/src/cli/types.ts +86 -1
  225. package/src/cli/watch.ts +298 -0
  226. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -1,20 +1,39 @@
1
1
  import { execFile as execFileCb } from 'node:child_process'
2
2
  import { createHash } from 'node:crypto'
3
- import { mkdirSync } from 'node:fs'
3
+ import {
4
+ appendFileSync,
5
+ closeSync,
6
+ existsSync,
7
+ fsyncSync,
8
+ mkdirSync,
9
+ openSync,
10
+ readFileSync,
11
+ renameSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs'
4
15
  import { dirname, join, resolve } from 'node:path'
16
+ import { DatabaseSync } from 'node:sqlite'
5
17
  import { promisify } from 'node:util'
6
- import type { Task, TaskSpec, AgentAdapter, ExecuteResult } from '../types.js'
7
- import { createConvoyStore, type ConvoyStore } from './store.js'
18
+ import type { Task, TaskSpec, AgentAdapter, ExecuteResult, ReviewHeuristics } from '../types.js'
19
+ import { createConvoyStore, ConvoyArtifactLimitError, type ConvoyStore } from './store.js'
20
+ import { acquireEngineLock } from './lock.js'
8
21
  import { createEventEmitter, type ConvoyEventEmitter } from './events.js'
9
22
  import { createWorktreeManager, type WorktreeManager } from './worktree.js'
10
- import { createMergeQueue, type MergeQueue } from './merge.js'
11
- import { createHealthMonitor } from './health.js'
23
+ import { createMergeQueue, MergeConflictError, type MergeQueue } from './merge.js'
24
+ import { createHealthMonitor, detectDrift } from './health.js'
12
25
  import { exportConvoyToNdjson } from './export.js'
13
- import type { TaskRecord, ConvoyStatus } from './types.js'
26
+ import type { TaskRecord, ConvoyStatus, ConvoyTaskStatus, GuardConfig, CircuitBreakerConfig, TaskStep, Hook, TaskOutput, TaskInput } from './types.js'
14
27
  import { buildPhases, formatDuration } from '../run/executor.js'
15
- import { parseTimeout } from '../run/schema.js'
28
+ import { parseTimeout, parseYaml } from '../run/schema.js'
16
29
  import { getAdapter, detectAdapter } from '../run/adapters/index.js'
17
30
  import { c } from '../prompt.js'
31
+ import { validateFilePartitions, scanSymlinks, scanNewSymlinks, normalizePath, pathsOverlap } from './partition.js'
32
+ import { scanForSecrets, runSecretScanGate, runBlastRadiusGate, browserTestGate } from './gates.js'
33
+ import { readLessons, captureLessons, consolidateLessons } from './lessons.js'
34
+ import { updateExpertise, feedCircuitBreaker } from './expertise.js'
35
+ import { buildKnowledgeGraph } from './knowledge.js'
36
+ import { injectDiscoveredIssuesInstruction, checkDiscoveredIssues, consolidateIssues } from './issues.js'
18
37
 
19
38
  const execFile = promisify(execFileCb)
20
39
 
@@ -31,6 +50,10 @@ export interface ConvoyEngineOptions {
31
50
  pipelineId?: string
32
51
  _worktreeManager?: WorktreeManager
33
52
  _mergeQueue?: MergeQueue
53
+ /** Override for test injection. Pass `ensureBranch` for real behavior, or a mock. */
54
+ _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
55
+ /** Injectable for test injection of the review pipeline. */
56
+ _reviewRunner?: (task: TaskRecord, level: ReviewLevel, reviewerModel: string) => Promise<ReviewResult>
34
57
  }
35
58
 
36
59
  export interface ConvoyResult {
@@ -45,16 +68,476 @@ export interface ConvoyResult {
45
68
  export interface ConvoyEngine {
46
69
  run(): Promise<ConvoyResult>
47
70
  resume(convoyId: string): Promise<ConvoyResult>
71
+ retryFailed(convoyId: string, taskIds?: string[]): Promise<void>
72
+ injectTask(convoyId: string, task: {
73
+ id: string
74
+ prompt: string
75
+ agent: string
76
+ phase: number
77
+ timeout_ms?: number
78
+ depends_on?: string[]
79
+ files?: string[]
80
+ max_retries?: number
81
+ provenance?: string
82
+ idempotency_key?: string
83
+ on_exhausted?: 'dlq' | 'skip' | 'stop'
84
+ }): TaskRecord
85
+ }
86
+
87
+ // ── Circuit Breaker ────────────────────────────────────────────────────────────
88
+
89
+ export interface CircuitBreakerState {
90
+ status: 'closed' | 'open' | 'half-open'
91
+ failures: number
92
+ last_failure_at: string | null
93
+ opened_at: string | null
94
+ }
95
+
96
+ export class CircuitBreakerManager {
97
+ private states: Map<string, CircuitBreakerState> = new Map()
98
+ private threshold: number
99
+ private cooldownMs: number
100
+ private fallbackAgent: string | null
101
+
102
+ constructor(config?: CircuitBreakerConfig, initialState?: Record<string, CircuitBreakerState>) {
103
+ this.threshold = config?.threshold ?? 3
104
+ this.cooldownMs = config?.cooldown_ms ?? 300_000
105
+ this.fallbackAgent = config?.fallback_agent ?? null
106
+
107
+ if (initialState) {
108
+ for (const [agent, state] of Object.entries(initialState)) {
109
+ this.states.set(agent, state)
110
+ }
111
+ }
112
+ }
113
+
114
+ getState(agent: string): CircuitBreakerState {
115
+ return this.states.get(agent) ?? { status: 'closed', failures: 0, last_failure_at: null, opened_at: null }
116
+ }
117
+
118
+ recordFailure(agent: string): { tripped: boolean; state: CircuitBreakerState } {
119
+ const state = this.getState(agent)
120
+ const now = new Date().toISOString()
121
+
122
+ if (state.status === 'half-open') {
123
+ // Probe failed — back to open, reset cooldown
124
+ state.status = 'open'
125
+ state.opened_at = now
126
+ state.last_failure_at = now
127
+ this.states.set(agent, state)
128
+ return { tripped: true, state }
129
+ }
130
+
131
+ state.failures += 1
132
+ state.last_failure_at = now
133
+
134
+ if (state.failures >= this.threshold) {
135
+ state.status = 'open'
136
+ state.opened_at = now
137
+ this.states.set(agent, state)
138
+ return { tripped: true, state }
139
+ }
140
+
141
+ this.states.set(agent, state)
142
+ return { tripped: false, state }
143
+ }
144
+
145
+ recordSuccess(agent: string): CircuitBreakerState {
146
+ const state = this.getState(agent)
147
+
148
+ if (state.status === 'half-open') {
149
+ // Probe succeeded — close circuit
150
+ state.status = 'closed'
151
+ state.failures = 0
152
+ state.opened_at = null
153
+ } else if (state.status === 'closed') {
154
+ state.failures = 0
155
+ }
156
+
157
+ this.states.set(agent, state)
158
+ return state
159
+ }
160
+
161
+ canAssign(agent: string): boolean {
162
+ const state = this.getState(agent)
163
+
164
+ if (state.status === 'closed') return true
165
+ if (state.status === 'half-open') return true // allow 1 probe
166
+
167
+ // Open — check cooldown
168
+ if (state.opened_at) {
169
+ const elapsed = Date.now() - new Date(state.opened_at).getTime()
170
+ if (elapsed >= this.cooldownMs) {
171
+ state.status = 'half-open'
172
+ this.states.set(agent, state)
173
+ return true
174
+ }
175
+ }
176
+
177
+ return false
178
+ }
179
+
180
+ get fallback(): string | null {
181
+ return this.fallbackAgent
182
+ }
183
+
184
+ serialize(): string {
185
+ return JSON.stringify(Object.fromEntries(this.states))
186
+ }
187
+ }
188
+
189
+ // ── Branch management ───────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Ensure the given branch exists and is checked out.
193
+ * Creates the branch from HEAD if it does not yet exist.
194
+ * Fails fast if there are uncommitted changes.
195
+ */
196
+ export async function ensureBranch(branchName: string, basePath: string): Promise<void> {
197
+ // Validate refspec — reject shell metacharacters
198
+ if (!/^[a-zA-Z0-9\-/_\.]+$/.test(branchName)) {
199
+ throw new Error(
200
+ `Invalid branch name "${branchName}": only alphanumeric, -, /, _, and . are allowed`,
201
+ )
202
+ }
203
+
204
+ // Refuse to switch branches with uncommitted changes
205
+ const { stdout: statusOut } = await execFile('git', ['status', '--porcelain'], {
206
+ cwd: basePath,
207
+ })
208
+ if (statusOut.trim()) {
209
+ throw new Error(
210
+ `Uncommitted changes detected in "${basePath}". Commit or stash before switching branches.`,
211
+ )
212
+ }
213
+
214
+ // Check if branch already exists
215
+ try {
216
+ await execFile('git', ['rev-parse', '--verify', branchName], { cwd: basePath })
217
+ // Branch exists — check it out
218
+ await execFile('git', ['checkout', branchName], { cwd: basePath })
219
+ } catch {
220
+ // Branch does not exist — create from current HEAD
221
+ await execFile('git', ['checkout', '-b', branchName], { cwd: basePath })
222
+ }
48
223
  }
49
224
 
50
225
  // ── Internal helpers ──────────────────────────────────────────────────────────
51
226
 
227
+ /**
228
+ * Truncate any trailing partial line in the NDJSON file, then replay any SQLite
229
+ * events for the given convoy that are missing from the file.
230
+ * Exported for unit testing.
231
+ */
232
+ function safeJsonParse(raw: string): Record<string, unknown> {
233
+ try {
234
+ return JSON.parse(raw) as Record<string, unknown>
235
+ } catch {
236
+ return {}
237
+ }
238
+ }
239
+
240
+ export function recoverNdjson(store: ConvoyStore, convoyId: string, ndjsonPath: string): void {
241
+ // 1. Read the NDJSON file (if it exists)
242
+ let fileContent: string
243
+ try {
244
+ fileContent = readFileSync(ndjsonPath, 'utf8')
245
+ } catch {
246
+ fileContent = ''
247
+ }
248
+
249
+ // 2. Truncate any partial trailing line (no \n terminator)
250
+ if (fileContent.length > 0 && !fileContent.endsWith('\n')) {
251
+ const lastNewline = fileContent.lastIndexOf('\n')
252
+ if (lastNewline === -1) {
253
+ writeFileSync(ndjsonPath, '')
254
+ fileContent = ''
255
+ } else {
256
+ writeFileSync(ndjsonPath, fileContent.slice(0, lastNewline + 1))
257
+ fileContent = fileContent.slice(0, lastNewline + 1)
258
+ }
259
+ }
260
+
261
+ // 3. Count valid NDJSON event IDs for this convoy
262
+ const ndjsonIds = new Set<number>()
263
+ for (const line of fileContent.split('\n')) {
264
+ if (!line.trim()) continue
265
+ try {
266
+ const parsed = JSON.parse(line) as Record<string, unknown>
267
+ if (parsed.convoy_id === convoyId && parsed._event_id != null) {
268
+ ndjsonIds.add(parsed._event_id as number)
269
+ }
270
+ } catch {
271
+ // Skip unparseable lines
272
+ }
273
+ }
274
+
275
+ // 4. Get all SQLite events for this convoy
276
+ const sqliteEvents = store.getEvents(convoyId)
277
+
278
+ // 5. Replay missing events (those in SQLite but not in NDJSON)
279
+ const missing = sqliteEvents.filter(e => e.id != null && !ndjsonIds.has(e.id!))
280
+ if (missing.length > 0) {
281
+ const fd = openSync(ndjsonPath, 'a')
282
+ try {
283
+ for (const event of missing) {
284
+ const parsedData = event.data ? safeJsonParse(event.data) : {}
285
+ const record = {
286
+ ...parsedData,
287
+ _event_id: event.id,
288
+ timestamp: event.created_at,
289
+ type: event.type,
290
+ convoy_id: event.convoy_id,
291
+ task_id: event.task_id,
292
+ worker_id: event.worker_id,
293
+ }
294
+ appendFileSync(fd, JSON.stringify(record) + '\n')
295
+ }
296
+ fsyncSync(fd)
297
+ } finally {
298
+ closeSync(fd)
299
+ }
300
+ }
301
+ }
302
+
303
+ // ── Convoy guard ──────────────────────────────────────────────────────────────
304
+
305
+ export interface ConvoyGuardResult {
306
+ passed: boolean
307
+ warnings: string[]
308
+ }
309
+
310
+ export function runConvoyGuard(
311
+ store: ConvoyStore,
312
+ convoyId: string,
313
+ _wtManager: WorktreeManager,
314
+ ndjsonPath: string,
315
+ guardConfig?: GuardConfig,
316
+ ): ConvoyGuardResult {
317
+ // If guard is explicitly disabled, skip all checks
318
+ if (guardConfig?.enabled === false) {
319
+ return { passed: true, warnings: [] }
320
+ }
321
+
322
+ const warnings: string[] = []
323
+ const tasks = store.getTasksByConvoy(convoyId)
324
+
325
+ // Check 1: All task statuses are terminal
326
+ const terminalStatuses = new Set(['done', 'failed', 'skipped', 'timed-out', 'gate-failed', 'review-blocked', 'hook-failed', 'disputed'])
327
+ const nonTerminal = tasks.filter(t => !terminalStatuses.has(t.status))
328
+ if (nonTerminal.length > 0) {
329
+ warnings.push(
330
+ `Non-terminal tasks: ${nonTerminal.map(t => `${t.id}(${t.status})`).join(', ')}`,
331
+ )
332
+ }
333
+
334
+ // Check 2: NDJSON file exists and record count >= completed task count
335
+ const completedTasks = tasks.filter(t => t.status === 'done')
336
+ try {
337
+ const content = readFileSync(ndjsonPath, 'utf8')
338
+ const lines = content.split('\n').filter(l => l.trim())
339
+ const convoyLines = lines.filter(l => {
340
+ try { return (JSON.parse(l) as Record<string, unknown>).convoy_id === convoyId } catch { return false }
341
+ })
342
+ if (convoyLines.length < completedTasks.length) {
343
+ warnings.push(
344
+ `NDJSON record count (${convoyLines.length}) < completed tasks (${completedTasks.length})`,
345
+ )
346
+ }
347
+ } catch {
348
+ if (completedTasks.length > 0) {
349
+ warnings.push(
350
+ `NDJSON file not found at ${ndjsonPath} but ${completedTasks.length} tasks completed`,
351
+ )
352
+ }
353
+ }
354
+
355
+ // Check 3: Every retried task has events for each attempt
356
+ const retriedTasks = tasks.filter(t => t.retries > 0)
357
+ const events = store.getEvents(convoyId)
358
+ for (const task of retriedTasks) {
359
+ const taskEvents = events.filter(e => e.task_id === task.id && e.type === 'task_started')
360
+ if (taskEvents.length < task.retries) {
361
+ warnings.push(
362
+ `Task ${task.id} has ${task.retries} retries but only ${taskEvents.length} task_started events`,
363
+ )
364
+ }
365
+ }
366
+
367
+ // Check 4: Gate results recorded for all gates that ran
368
+ const gateEvents = events.filter(e =>
369
+ e.type === 'built_in_gate_result' || (e.data != null && e.data.includes('gate')),
370
+ )
371
+ const tasksWithGates = tasks.filter(t => t.gates)
372
+ if (tasksWithGates.length > 0 && gateEvents.length === 0) {
373
+ warnings.push('Tasks have gates configured but no gate result events found')
374
+ }
375
+
376
+ // Check 5: Token/cost totals computed
377
+ const convoy = store.getConvoy(convoyId)
378
+ if (convoy && convoy.total_tokens == null) {
379
+ const totalTokens = tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0)
380
+ if (totalTokens > 0) {
381
+ warnings.push('Convoy total_tokens not persisted despite tasks having token data')
382
+ }
383
+ }
384
+
385
+ // Check 6: No orphaned worktrees — engine already calls removeAll() during cleanup.
386
+ // Synchronous check is not possible; the engine handles this.
387
+
388
+ return { passed: warnings.length === 0, warnings }
389
+ }
390
+
391
+ // ── Review routing ────────────────────────────────────────────────────────────
392
+
393
+ export interface DiffStats {
394
+ linesChanged: number
395
+ filesChanged: number
396
+ filePaths: string[]
397
+ }
398
+
399
+ export type ReviewLevel = 'auto-pass' | 'fast' | 'panel'
400
+
401
+ export interface ReviewResult {
402
+ verdict: 'pass' | 'block'
403
+ feedback: string
404
+ tokens: number
405
+ model: string
406
+ }
407
+
408
+ export function evaluateReviewLevel(
409
+ task: TaskRecord,
410
+ diff: DiffStats,
411
+ heuristics?: ReviewHeuristics,
412
+ allGatesPassed?: boolean,
413
+ ): ReviewLevel {
414
+ const panelPaths = heuristics?.panel_paths ?? ['auth/', 'security/', 'migrations/', 'rls/']
415
+ const panelAgents = heuristics?.panel_agents ?? ['security-expert', 'database-engineer']
416
+ const autoPassAgents = heuristics?.auto_pass_agents ?? ['documentation-writer', 'copywriter']
417
+ const autoPassMaxLines = heuristics?.auto_pass_max_lines ?? 10
418
+ const autoPassMaxFiles = heuristics?.auto_pass_max_files ?? 2
419
+
420
+ // Panel: sensitive paths or agents
421
+ if (panelPaths.some(p => diff.filePaths.some(fp => fp.startsWith(p) || fp.includes('/' + p)))) return 'panel'
422
+ if (panelAgents.includes(task.agent)) return 'panel'
423
+
424
+ // Auto-pass: documentation/copy agents
425
+ if (autoPassAgents.includes(task.agent)) return 'auto-pass'
426
+
427
+ // Auto-pass: small diffs with all gates passing
428
+ if (diff.linesChanged <= autoPassMaxLines && diff.filesChanged <= autoPassMaxFiles && allGatesPassed !== false) return 'auto-pass'
429
+
430
+ // Large diffs → fast review
431
+ if (diff.linesChanged > 200 || diff.filesChanged > 5) return 'fast'
432
+
433
+ // Default → fast review
434
+ return 'fast'
435
+ }
436
+
437
+ class ReviewSemaphore {
438
+ private current = 0
439
+ private queue: Array<() => void> = []
440
+ constructor(private max: number) {}
441
+
442
+ async acquire(): Promise<void> {
443
+ if (this.current < this.max) {
444
+ this.current++
445
+ return
446
+ }
447
+ return new Promise<void>(resolve => {
448
+ this.queue.push(() => { this.current++; resolve() })
449
+ })
450
+ }
451
+
452
+ release(): void {
453
+ this.current--
454
+ if (this.queue.length > 0) {
455
+ const next = this.queue.shift()!
456
+ next()
457
+ }
458
+ }
459
+ }
460
+
52
461
  function msToTimeout(ms: number): string {
53
462
  if (ms >= 3_600_000 && ms % 3_600_000 === 0) return `${ms / 3_600_000}h`
54
463
  if (ms >= 60_000 && ms % 60_000 === 0) return `${ms / 60_000}m`
55
464
  return `${ms / 1_000}s`
56
465
  }
57
466
 
467
+ // ── DLQ markdown dual-write ───────────────────────────────────────────────────
468
+
469
+ // Builds the DLQ markdown entry text (no I/O, no scanning).
470
+ function buildDlqMarkdownEntry(
471
+ dlqId: string,
472
+ task: TaskRecord,
473
+ failureType: string,
474
+ errorOutput: string | null,
475
+ ): { marker: string; entry: string } {
476
+ const marker = `<!-- dlq:${dlqId} -->`
477
+ const entry = `\n${marker}\n### ${dlqId}\n\n| Field | Value |\n|-------|-------|\n| Task | ${task.id} |\n| Agent | ${task.agent} |\n| Type | ${failureType} |\n| Attempts | ${task.retries + 1} |\n| Date | ${new Date().toISOString()} |\n\n**Error:**\n\`\`\`\n${(errorOutput ?? '(no output)').slice(0, 2000)}\n\`\`\`\n`
478
+ return { marker, entry }
479
+ }
480
+
481
+ // Appends a pre-scanned DLQ entry to AGENT-FAILURES.md. The caller must have
482
+ // already verified the entry is clean via scanForSecrets — no re-scan here.
483
+ function appendDlqMarkdownClean(marker: string, entry: string): void {
484
+ const mdPath = join(resolve(process.cwd()), '.opencastle', 'AGENT-FAILURES.md')
485
+ try {
486
+ const existing = readFileSync(mdPath, 'utf8')
487
+ if (existing.includes(marker)) return
488
+ } catch {
489
+ // File doesn't exist yet — will create
490
+ }
491
+ mkdirSync(dirname(mdPath), { recursive: true })
492
+ appendFileSync(mdPath, entry)
493
+ }
494
+
495
+ function writeDisputeToMarkdown(
496
+ disputeId: string,
497
+ convoyId: string,
498
+ task: TaskRecord,
499
+ panelResults: ReviewResult[],
500
+ events?: ConvoyEventEmitter | null,
501
+ ): void {
502
+ const mdPath = join(resolve(process.cwd()), 'DISPUTES.md')
503
+ const marker = `<!-- dispute:${disputeId} -->`
504
+
505
+ try {
506
+ const existing = readFileSync(mdPath, 'utf8')
507
+ if (existing.includes(marker)) return
508
+ } catch {
509
+ // File doesn't exist yet
510
+ }
511
+
512
+ const blockingReasons = panelResults
513
+ .filter(r => r.verdict === 'block')
514
+ .map(r => r.feedback)
515
+ .join('\n\n')
516
+
517
+ const entry = `\n${marker}\n## Dispute: ${task.id}\n\n| Field | Value |\n|-------|-------|\n| Convoy | ${convoyId} |\n| Task | ${task.id} |\n| Date | ${new Date().toISOString()} |\n| Panel attempts | ${task.panel_attempts + 1} |\n| Agent | ${task.agent} |\n| Status | Open |\n\n**Blocking reasons:**\n\n${blockingReasons}\n`
518
+
519
+ const scanResult = scanForSecrets(entry, 'DISPUTES.md')
520
+ if (!scanResult.clean) {
521
+ if (events) {
522
+ events.emit(
523
+ 'secret_leak_prevented',
524
+ {
525
+ task_id: task.id,
526
+ findings_count: scanResult.findings.length,
527
+ patterns: scanResult.findings.map((f) => f.pattern),
528
+ context: 'dispute_markdown_write',
529
+ },
530
+ { convoy_id: convoyId, task_id: task.id },
531
+ )
532
+ }
533
+ return
534
+ }
535
+
536
+ appendFileSync(mdPath, entry)
537
+ }
538
+
539
+
540
+
58
541
  function taskRecordToTask(record: TaskRecord): Task {
59
542
  return {
60
543
  id: record.id,
@@ -67,6 +550,7 @@ function taskRecordToTask(record: TaskRecord): Task {
67
550
  model: record.model ?? undefined,
68
551
  max_retries: record.max_retries,
69
552
  adapter: record.adapter ?? undefined,
553
+ gates: record.gates ? (JSON.parse(record.gates) as string[]) : undefined,
70
554
  }
71
555
  }
72
556
 
@@ -81,6 +565,354 @@ function makeTimeoutPromise(ms: number): { promise: Promise<ExecuteResult>; clea
81
565
  return { promise, clear: () => { if (timerId !== undefined) clearTimeout(timerId) } }
82
566
  }
83
567
 
568
+ // ── Step condition evaluation ─────────────────────────────────────────────────
569
+
570
+ function evaluateStepCondition(
571
+ condition: TaskStep['if'],
572
+ stepResults: Map<string, { exitCode: number }>,
573
+ worktreePath: string | null,
574
+ basePath: string,
575
+ ): boolean {
576
+ if (!condition) return true
577
+
578
+ if (condition.exitCode) {
579
+ const prevResult = stepResults.get(condition.step)
580
+ if (!prevResult) return false
581
+ const code = prevResult.exitCode
582
+ const ec = condition.exitCode
583
+ if (ec.eq !== undefined && code !== ec.eq) return false
584
+ if (ec.ne !== undefined && code === ec.ne) return false
585
+ if (ec.gt !== undefined && !(code > ec.gt)) return false
586
+ if (ec.lt !== undefined && !(code < ec.lt)) return false
587
+ }
588
+
589
+ if (condition.fileExists) {
590
+ const base = worktreePath ?? basePath
591
+ if (condition.fileExists.path.startsWith('/')) {
592
+ return false // Absolute paths not allowed in step conditions
593
+ }
594
+ const filePath = join(base, condition.fileExists.path)
595
+ const resolved = resolve(filePath)
596
+ const resolvedBase = resolve(base)
597
+ if (!resolved.startsWith(resolvedBase + '/') && resolved !== resolvedBase) {
598
+ return false // path escapes the worktree — treat as "file doesn't exist"
599
+ }
600
+ if (!existsSync(filePath)) return false
601
+ }
602
+
603
+ return true
604
+ }
605
+
606
+ async function executeSteps(
607
+ taskRecord: TaskRecord,
608
+ steps: TaskStep[],
609
+ adapter: AgentAdapter,
610
+ worktreePath: string | null,
611
+ basePath: string,
612
+ store: ConvoyStore,
613
+ convoyId: string,
614
+ verbose: boolean,
615
+ ): Promise<ExecuteResult> {
616
+ const now = () => new Date().toISOString()
617
+ const stepResults = new Map<string, { exitCode: number }>()
618
+ let combinedOutput = ''
619
+ let lastExitCode = 0
620
+
621
+ // Track total_steps in DB
622
+ store.updateTaskStatus(taskRecord.id, convoyId, 'running', {})
623
+
624
+ for (let i = 0; i < steps.length; i++) {
625
+ const step = steps[i]
626
+
627
+ // Evaluate condition — skip step if condition is not met
628
+ if (step.if) {
629
+ const condMet = evaluateStepCondition(step.if, stepResults, worktreePath, basePath)
630
+ if (!condMet) {
631
+ const stepId = store.insertTaskStep({
632
+ task_id: taskRecord.id,
633
+ step_index: i,
634
+ prompt: step.prompt,
635
+ gates: step.gates ? JSON.stringify(step.gates) : null,
636
+ status: 'skipped',
637
+ exit_code: null,
638
+ output: 'Skipped: condition not met',
639
+ started_at: now(),
640
+ finished_at: now(),
641
+ })
642
+ if (step.id) {
643
+ stepResults.set(step.id, { exitCode: 0 })
644
+ }
645
+ combinedOutput += `\n[Step ${i + 1} skipped: condition not met]`
646
+ continue
647
+ }
648
+ }
649
+
650
+ // Insert step record as running
651
+ const stepDbId = store.insertTaskStep({
652
+ task_id: taskRecord.id,
653
+ step_index: i,
654
+ prompt: step.prompt,
655
+ gates: step.gates ? JSON.stringify(step.gates) : null,
656
+ status: 'running',
657
+ exit_code: null,
658
+ output: null,
659
+ started_at: now(),
660
+ finished_at: null,
661
+ })
662
+
663
+ // Update current_step on the task record
664
+ store.updateTaskStatus(taskRecord.id, convoyId, 'running', {})
665
+
666
+ const stepMaxRetries = step.max_retries ?? taskRecord.max_retries
667
+ let stepResult: ExecuteResult = { success: false, output: '', exitCode: -1 }
668
+ let stepAttempt = 0
669
+
670
+ while (stepAttempt <= stepMaxRetries) {
671
+ // Prepend prior failure context on retries
672
+ let stepPrompt = step.prompt
673
+ if (stepAttempt > 0 && stepResult) {
674
+ const failedOutput = stepResult.output || '(no output)'
675
+ stepPrompt = `Previous attempt failed.\nExit code: ${stepResult.exitCode}\nError output:\n${failedOutput}\n\nFix the issues and try again.\n\n` + step.prompt
676
+ }
677
+
678
+ const stepTask = {
679
+ id: taskRecord.id,
680
+ prompt: stepPrompt,
681
+ agent: taskRecord.agent,
682
+ timeout: `${taskRecord.timeout_ms}ms`,
683
+ depends_on: [],
684
+ files: taskRecord.files ? JSON.parse(taskRecord.files) as string[] : [],
685
+ description: `step ${i + 1}`,
686
+ max_retries: stepMaxRetries,
687
+ }
688
+
689
+ try {
690
+ stepResult = await adapter.execute(stepTask, { verbose, cwd: worktreePath ?? basePath })
691
+ } catch (err) {
692
+ stepResult = { success: false, output: (err as Error).message, exitCode: -1 }
693
+ }
694
+
695
+ if (stepResult.success) break
696
+
697
+ stepAttempt++
698
+ if (stepAttempt <= stepMaxRetries) {
699
+ process.stdout.write(` ↺ step ${i + 1}/${steps.length} failed, retry ${stepAttempt}/${stepMaxRetries}\n`)
700
+ }
701
+ }
702
+
703
+ lastExitCode = stepResult.exitCode
704
+ combinedOutput += `\n[Step ${i + 1}]\n${stepResult.output}`
705
+
706
+ if (step.id) {
707
+ stepResults.set(step.id, { exitCode: stepResult.exitCode })
708
+ }
709
+
710
+ // Run step-level gates if present
711
+ if (step.gates && step.gates.length > 0 && stepResult.success) {
712
+ let gateFailure: { command: string; exitCode: number; output: string } | null = null
713
+ const execFileCb = (await import('node:child_process')).execFile
714
+ const execFileP = (await import('node:util')).promisify(execFileCb)
715
+ for (const command of step.gates) {
716
+ try {
717
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
718
+ // They are NOT user-supplied and are part of the trusted build configuration.
719
+ await execFileP('sh', ['-c', command], { cwd: worktreePath ?? basePath })
720
+ } catch (gateErr) {
721
+ const ge = gateErr as Error & { code?: unknown; stderr?: string; stdout?: string }
722
+ const code = typeof ge.code === 'number' ? ge.code : 1
723
+ const output = ge.stderr || ge.stdout || ge.message || ''
724
+ gateFailure = { command, exitCode: code, output }
725
+ break
726
+ }
727
+ }
728
+ if (gateFailure !== null) {
729
+ stepResult = { success: false, output: `Gate failed: ${gateFailure.command}\nExit code: ${gateFailure.exitCode}\n${gateFailure.output}`, exitCode: gateFailure.exitCode }
730
+ lastExitCode = gateFailure.exitCode
731
+ combinedOutput += `\n[Step ${i + 1} gate failed: ${gateFailure.command}]`
732
+ }
733
+ }
734
+
735
+ // Update step record
736
+ store.updateTaskStep(stepDbId, {
737
+ status: stepResult.success ? 'done' : 'failed',
738
+ exit_code: stepResult.exitCode,
739
+ output: stepResult.output,
740
+ finished_at: now(),
741
+ })
742
+
743
+ if (!stepResult.success) {
744
+ return {
745
+ success: false,
746
+ output: combinedOutput.trim(),
747
+ exitCode: lastExitCode,
748
+ }
749
+ }
750
+ }
751
+
752
+ return {
753
+ success: true,
754
+ output: combinedOutput.trim(),
755
+ exitCode: lastExitCode,
756
+ }
757
+ }
758
+
759
+ // ── File-based injection ──────────────────────────────────────────────────────
760
+
761
+ const INJECT_DIR = '.opencastle/convoy-inject'
762
+ const CONVOY_ID_RE = /^[a-zA-Z0-9-]+$/
763
+ const MAX_FILE_INJECTED_TASKS = 10
764
+
765
+ function pollInjectFile(
766
+ convoyId: string,
767
+ store: ConvoyStore,
768
+ events: ConvoyEventEmitter,
769
+ basePath: string,
770
+ ): number {
771
+ // Path traversal guard: convoy_id must be alphanumeric + hyphens only
772
+ if (!CONVOY_ID_RE.test(convoyId)) return 0
773
+
774
+ const injectDir = join(basePath, INJECT_DIR, convoyId)
775
+ const injectPath = join(injectDir, 'inject.yml')
776
+
777
+ if (!existsSync(injectPath)) return 0
778
+
779
+ // Atomic rename to prevent double-read
780
+ const processingPath = injectPath + '.processing'
781
+ try {
782
+ renameSync(injectPath, processingPath)
783
+ } catch {
784
+ return 0 // Another process may have grabbed it
785
+ }
786
+
787
+ let raw: string
788
+ try {
789
+ raw = readFileSync(processingPath, 'utf8')
790
+ } catch {
791
+ return 0
792
+ } finally {
793
+ try { unlinkSync(processingPath) } catch { /* ignore */ }
794
+ }
795
+
796
+ let parsed: Record<string, unknown>
797
+ try {
798
+ parsed = parseYaml(raw)
799
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.tasks)) {
800
+ process.stderr.write(`Warning: inject file has invalid format (expected { tasks: [...] })\n`)
801
+ return 0
802
+ }
803
+ } catch (err) {
804
+ process.stderr.write(`Warning: failed to parse inject file: ${(err as Error).message}\n`)
805
+ return 0
806
+ }
807
+
808
+ const tasks = parsed.tasks as Array<Record<string, unknown>>
809
+ const allExisting = store.getTasksByConvoy(convoyId)
810
+ const existingFileInjected = allExisting.filter(t => t.provenance === 'file-injection').length
811
+ const remaining = MAX_FILE_INJECTED_TASKS - existingFileInjected
812
+ let injectedCount = 0
813
+
814
+ for (const rawTask of tasks) {
815
+ if (injectedCount >= remaining) {
816
+ process.stderr.write(`Warning: file injection limit reached (${MAX_FILE_INJECTED_TASKS}), skipping remaining tasks\n`)
817
+ break
818
+ }
819
+
820
+ // Validate required fields
821
+ if (!rawTask.id || typeof rawTask.id !== 'string') {
822
+ process.stderr.write(`Warning: skipping injected task with missing/invalid id\n`)
823
+ continue
824
+ }
825
+ if (!rawTask.prompt || typeof rawTask.prompt !== 'string') {
826
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": missing prompt\n`)
827
+ continue
828
+ }
829
+ if (!rawTask.agent || typeof rawTask.agent !== 'string') {
830
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": missing agent\n`)
831
+ continue
832
+ }
833
+
834
+ // Check ID uniqueness
835
+ if (allExisting.some(t => t.id === rawTask.id as string)) {
836
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": ID already exists\n`)
837
+ continue
838
+ }
839
+
840
+ // Determine phase — inject into last scheduled phase
841
+ const maxPhase = allExisting.reduce((max, t) => Math.max(max, t.phase), 0)
842
+
843
+ // Validate file paths before building the record
844
+ let validatedFiles: string | null = null
845
+ if (rawTask.files && Array.isArray(rawTask.files)) {
846
+ try {
847
+ validatedFiles = JSON.stringify((rawTask.files as string[]).map(f => normalizePath(f as string)))
848
+ } catch (err) {
849
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id as string}": invalid file path: ${(err as Error).message}\n`)
850
+ continue
851
+ }
852
+ }
853
+
854
+ const record: TaskRecord = {
855
+ id: rawTask.id as string,
856
+ convoy_id: convoyId,
857
+ phase: maxPhase,
858
+ prompt: rawTask.prompt as string,
859
+ agent: rawTask.agent as string,
860
+ adapter: null,
861
+ model: null,
862
+ timeout_ms: typeof rawTask.timeout_ms === 'number' ? rawTask.timeout_ms : 1_800_000,
863
+ status: 'pending',
864
+ worker_id: null,
865
+ worktree: null,
866
+ output: null,
867
+ exit_code: null,
868
+ started_at: null,
869
+ finished_at: null,
870
+ retries: 0,
871
+ max_retries: typeof rawTask.max_retries === 'number' ? rawTask.max_retries : 1,
872
+ files: validatedFiles,
873
+ depends_on: null,
874
+ prompt_tokens: null,
875
+ completion_tokens: null,
876
+ total_tokens: null,
877
+ cost_usd: null,
878
+ gates: null,
879
+ on_exhausted: 'dlq',
880
+ injected: 1,
881
+ provenance: 'file-injection',
882
+ idempotency_key: null,
883
+ current_step: null,
884
+ total_steps: null,
885
+ review_level: null,
886
+ review_verdict: null,
887
+ review_tokens: null,
888
+ review_model: null,
889
+ panel_attempts: 0,
890
+ dispute_id: null,
891
+ drift_score: null,
892
+ drift_retried: 0,
893
+ outputs: null,
894
+ inputs: null,
895
+ discovered_issues: null,
896
+ }
897
+
898
+ try {
899
+ store.insertInjectedTask(record)
900
+ injectedCount++
901
+ } catch (err) {
902
+ process.stderr.write(`Warning: failed to inject task "${rawTask.id}": ${(err as Error).message}\n`)
903
+ }
904
+ }
905
+
906
+ if (injectedCount > 0) {
907
+ events.emit('file_injection_received', {
908
+ task_count: injectedCount,
909
+ source: injectPath,
910
+ }, { convoy_id: convoyId })
911
+ }
912
+
913
+ return injectedCount
914
+ }
915
+
84
916
  // ── Core convoy execution ─────────────────────────────────────────────────────
85
917
 
86
918
  async function runConvoy(
@@ -95,10 +927,14 @@ async function runConvoy(
95
927
  baseBranch: string,
96
928
  verbose: boolean,
97
929
  startTime: number,
930
+ ndjsonPath: string,
931
+ reviewRunner?: (task: TaskRecord, level: ReviewLevel, reviewerModel: string) => Promise<ReviewResult>,
98
932
  ): Promise<ConvoyResult> {
99
933
  const totalTasks = spec.tasks?.length ?? 0
100
934
  let completedCount = 0
101
935
  const activeTaskMap = new Map<string, Task>()
936
+ const reviewSemaphore = new ReviewSemaphore(spec.defaults?.max_concurrent_reviews ?? 3)
937
+ let reviewTokensTotal = 0
102
938
  const taskAdapterMap = new Map<string, AgentAdapter>()
103
939
 
104
940
  const healthMonitor = createHealthMonitor({
@@ -117,6 +953,19 @@ async function runConvoy(
117
953
  })
118
954
  healthMonitor.start()
119
955
 
956
+ // ── Circuit breaker ────────────────────────────────────────────────────────
957
+ const circuitBreakerConfig = spec.defaults?.circuit_breaker
958
+ const convoyRecord = store.getConvoy(convoyId)
959
+ const initialCircuitState = convoyRecord?.circuit_state ? JSON.parse(convoyRecord.circuit_state) : undefined
960
+ const circuitBreaker = new CircuitBreakerManager(circuitBreakerConfig, initialCircuitState)
961
+
962
+ // ── Trust model ────────────────────────────────────────────────────────────
963
+ // Gate commands, hook commands, and step commands in .convoy.yml are treated
964
+ // as operator-controlled build configuration (analogous to Makefiles, CI
965
+ // configs, or package.json scripts). They are executed via sh -c and must
966
+ // NOT contain user-supplied input. The spec file itself is the trust boundary.
967
+ // ──────────────────────────────────────────────────────────────────────────
968
+
120
969
  // ── Task skipping ─────────────────────────────────────────────────────────
121
970
 
122
971
  function skipTask(taskId: string, reason: string, visited: Set<string> = new Set()): void {
@@ -153,6 +1002,146 @@ async function runConvoy(
153
1002
  }
154
1003
  }
155
1004
 
1005
+ function handleExhaustion(taskRecord: TaskRecord, failureType: string, errorOutput: string | null): void {
1006
+ const exhausted = taskRecord.on_exhausted ?? 'dlq'
1007
+
1008
+ if (exhausted === 'dlq' || exhausted === 'stop') {
1009
+ const dlqId = `dlq-${taskRecord.id}-${Date.now()}`
1010
+
1011
+ // Pre-scan: build the markdown entry and check for secrets BEFORE any
1012
+ // writes. This keeps the SQLite DLQ row and the Markdown file in sync —
1013
+ // either both are written or neither is (MF-2 dual-write atomicity).
1014
+ const { marker: dlqMarker, entry: dlqMdEntry } = buildDlqMarkdownEntry(
1015
+ dlqId,
1016
+ taskRecord,
1017
+ failureType,
1018
+ errorOutput,
1019
+ )
1020
+ const dlqScanResult = scanForSecrets(dlqMdEntry, 'AGENT-FAILURES.md')
1021
+
1022
+ if (!dlqScanResult.clean) {
1023
+ // Block BOTH writes to maintain consistent state
1024
+ events.emit(
1025
+ 'secret_leak_prevented',
1026
+ {
1027
+ task_id: taskRecord.id,
1028
+ findings_count: dlqScanResult.findings.length,
1029
+ patterns: dlqScanResult.findings.map((f) => f.pattern),
1030
+ context: 'dlq_dual_write',
1031
+ },
1032
+ { convoy_id: convoyId, task_id: taskRecord.id },
1033
+ )
1034
+ } else {
1035
+ // Clean — proceed with both writes atomically
1036
+ store.insertDlqEntry({
1037
+ id: dlqId,
1038
+ convoy_id: convoyId,
1039
+ task_id: taskRecord.id,
1040
+ agent: taskRecord.agent,
1041
+ failure_type: failureType,
1042
+ error_output: errorOutput,
1043
+ attempts: taskRecord.retries + 1,
1044
+ tokens_spent: taskRecord.total_tokens,
1045
+ escalation_task_id: null,
1046
+ resolved: 0,
1047
+ resolution: null,
1048
+ created_at: new Date().toISOString(),
1049
+ resolved_at: null,
1050
+ })
1051
+ appendDlqMarkdownClean(dlqMarker, dlqMdEntry)
1052
+ events.emit('dlq_entry_created', {
1053
+ dlq_id: dlqId,
1054
+ task_id: taskRecord.id,
1055
+ agent: taskRecord.agent,
1056
+ failure_type: failureType,
1057
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1058
+ }
1059
+ }
1060
+
1061
+ if (exhausted === 'stop') {
1062
+ // Skip all remaining pending tasks + set convoy to failed
1063
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending')
1064
+ for (const t of allPending) {
1065
+ skipTask(t.id, `on_exhausted: stop — task "${taskRecord.id}" exhausted retries`)
1066
+ }
1067
+ store.updateConvoyStatus(convoyId, 'failed')
1068
+ } else if (exhausted === 'dlq' || exhausted === 'skip') {
1069
+ // Default behavior: cascade failure to dependents only
1070
+ cascadeFailure(taskRecord.id)
1071
+ }
1072
+
1073
+ // ── Circuit breaker: record exhaustion failure ──────────────────────────
1074
+ if (circuitBreakerConfig) {
1075
+ const { tripped } = circuitBreaker.recordFailure(taskRecord.agent)
1076
+ try { store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize()) } catch { /* non-critical */ }
1077
+ if (tripped) {
1078
+ events.emit('circuit_breaker_tripped', {
1079
+ agent: taskRecord.agent,
1080
+ state: circuitBreaker.getState(taskRecord.agent),
1081
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1082
+ }
1083
+ }
1084
+ }
1085
+
1086
+ // ── Hook execution ────────────────────────────────────────────────────────
1087
+
1088
+ async function runHooks(
1089
+ hooks: Hook[],
1090
+ lifecycle: 'pre_task' | 'post_task' | 'post_convoy',
1091
+ context: { taskId?: string; convoyId: string; cwd: string },
1092
+ ): Promise<{ passed: boolean; failedHook?: Hook; error?: string }> {
1093
+ const filtered = hooks.filter(h => (h.on ?? 'post_task') === lifecycle)
1094
+ for (const hook of filtered) {
1095
+ if (hook.type === 'command' || hook.type === 'guard' || hook.type === 'validate') {
1096
+ const cmd = hook.command
1097
+ if (!cmd) continue
1098
+ try {
1099
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
1100
+ // They are NOT user-supplied and are part of the trusted build configuration.
1101
+ await execFile('sh', ['-c', cmd], { cwd: context.cwd })
1102
+ } catch (err) {
1103
+ const execErr = err as Error & { stderr?: string; stdout?: string }
1104
+ const errorMsg = execErr.stderr || execErr.stdout || execErr.message || ''
1105
+ return { passed: false, failedHook: hook, error: errorMsg }
1106
+ }
1107
+ } else if (hook.type === 'agent') {
1108
+ if (!hook.prompt) continue
1109
+ const hookTask: Task = {
1110
+ id: `hook-${lifecycle}-${context.taskId ?? 'convoy'}-${Date.now()}`,
1111
+ prompt: hook.prompt,
1112
+ agent: hook.name ?? 'developer',
1113
+ timeout: '10m',
1114
+ depends_on: [],
1115
+ files: [],
1116
+ description: `Hook: ${hook.name ?? hook.type}`,
1117
+ max_retries: 0,
1118
+ }
1119
+ try {
1120
+ const hookResult = await adapter.execute(hookTask, { verbose, cwd: context.cwd })
1121
+ if (!hookResult.success) {
1122
+ return { passed: false, failedHook: hook, error: hookResult.output }
1123
+ }
1124
+ } catch (err) {
1125
+ return { passed: false, failedHook: hook, error: (err as Error).message }
1126
+ }
1127
+ } else if (hook.type === 'review') {
1128
+ if (!context.taskId || !reviewRunner) continue
1129
+ const reviewTaskRecord = store.getTask(context.taskId, context.convoyId)
1130
+ if (reviewTaskRecord) {
1131
+ const reviewResult = await reviewRunner(
1132
+ reviewTaskRecord,
1133
+ 'fast',
1134
+ spec.defaults?.reviewer_model ?? 'default',
1135
+ )
1136
+ if (reviewResult.verdict !== 'pass') {
1137
+ return { passed: false, failedHook: hook, error: reviewResult.feedback }
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+ return { passed: true }
1143
+ }
1144
+
156
1145
  // ── Single-task executor ──────────────────────────────────────────────────
157
1146
 
158
1147
  async function executeOneTask(taskRecord: TaskRecord): Promise<void> {
@@ -173,6 +1162,68 @@ async function runConvoy(
173
1162
  }
174
1163
  taskAdapterMap.set(taskRecord.id, taskAdapter)
175
1164
 
1165
+ // ── Check inputs availability ────────────────────────────────────────────
1166
+ if (taskRecord.inputs) {
1167
+ const inputs: TaskInput[] = JSON.parse(taskRecord.inputs)
1168
+ for (const input of inputs) {
1169
+ const artifact = store.getArtifact(convoyId, input.name)
1170
+ if (!artifact) {
1171
+ store.updateTaskStatus(taskRecord.id, convoyId, 'wait-for-input')
1172
+ events.emit('task_waiting_input', {
1173
+ task_id: taskRecord.id,
1174
+ missing_artifact: input.name,
1175
+ from_task: input.from,
1176
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1177
+ taskAdapterMap.delete(taskRecord.id)
1178
+ return
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ // ── Circuit breaker check ──────────────────────────────────────────────
1184
+ if (circuitBreakerConfig) {
1185
+ if (!circuitBreaker.canAssign(taskRecord.agent)) {
1186
+ const fallback = circuitBreaker.fallback
1187
+ if (fallback) {
1188
+ events.emit('circuit_breaker_fallback', {
1189
+ original_agent: taskRecord.agent,
1190
+ fallback_agent: fallback,
1191
+ state: circuitBreaker.getState(taskRecord.agent),
1192
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1193
+ } else {
1194
+ events.emit('circuit_breaker_blocked', {
1195
+ agent: taskRecord.agent,
1196
+ state: circuitBreaker.getState(taskRecord.agent),
1197
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1198
+ }
1199
+ store.updateTaskStatus(taskRecord.id, convoyId, 'skipped', {
1200
+ output: `Circuit breaker open for agent "${taskRecord.agent}". ${fallback ? `No fallback available.` : `No fallback configured.`}`,
1201
+ })
1202
+ completedCount++
1203
+ taskAdapterMap.delete(taskRecord.id)
1204
+ cascadeFailure(taskRecord.id)
1205
+ return
1206
+ }
1207
+ }
1208
+
1209
+ // ── Intelligence: circuit breaker weak-area avoidance (Phase 18.2) ─────
1210
+ if (spec.defaults?.avoid_weak_agents) {
1211
+ try {
1212
+ const weakAreas = feedCircuitBreaker(taskRecord.agent, basePath)
1213
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
1214
+ const matchesWeakArea = weakAreas.some(area =>
1215
+ taskFiles.some(f => f.toLowerCase().includes(area.toLowerCase()))
1216
+ )
1217
+ if (matchesWeakArea && taskRecord.retries === 0) {
1218
+ events.emit('weak_area_skipped', { agent: taskRecord.agent, weak_areas: weakAreas, task_files: taskFiles }, { convoy_id: convoyId, task_id: taskRecord.id })
1219
+ store.updateTaskStatus(taskRecord.id, convoyId, 'skipped', { output: `Agent "${taskRecord.agent}" has weak-area match for task files. Skipped by avoid_weak_agents policy.` })
1220
+ completedCount++
1221
+ taskAdapterMap.delete(taskRecord.id)
1222
+ return
1223
+ }
1224
+ } catch { /* non-critical */ }
1225
+ }
1226
+
176
1227
  // Create worktree (skip for copilot adapter)
177
1228
  let worktreePath: string | null = null
178
1229
  if (taskAdapter.name !== 'copilot') {
@@ -209,21 +1260,166 @@ async function runConvoy(
209
1260
  const task = taskRecordToTask(taskRecord)
210
1261
  activeTaskMap.set(taskRecord.id, task)
211
1262
 
212
- process.stdout.write(` ${c.cyan('▶')} ${c.bold(`[${taskRecord.id}]`)} ${taskRecord.agent}${worktreePath ? c.dim(' (worktree)') : ''}\n`)
213
- events.emit(
214
- 'task_started',
215
- { worker_id: workerId },
216
- { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
217
- )
1263
+ // ── Inject inputs into prompt ────────────────────────────────────────────
1264
+ if (taskRecord.inputs) {
1265
+ const inputs: TaskInput[] = JSON.parse(taskRecord.inputs)
1266
+ for (const input of inputs) {
1267
+ const artifact = store.getArtifact(convoyId, input.name)!
1268
+ const templateVar = input.as ?? input.name
1269
+ task.prompt = task.prompt.replaceAll(`{{input.${templateVar}}}`, artifact.content)
1270
+ }
1271
+ }
1272
+
1273
+ // ── Scratchpad template substitution (Phase 17.1) ───────────────────────
1274
+ const scratchpadRe = /\{\{scratchpad\.([a-zA-Z0-9_.-]+)\}\}/g
1275
+ let scratchpadMatch: RegExpExecArray | null
1276
+ while ((scratchpadMatch = scratchpadRe.exec(task.prompt)) !== null) {
1277
+ const spKey = scratchpadMatch[1]
1278
+ const spVal = store.getScratchpadValue(spKey)
1279
+ if (spVal !== null) {
1280
+ task.prompt = task.prompt.replaceAll(`{{scratchpad.${spKey}}}`, spVal)
1281
+ scratchpadRe.lastIndex = 0 // reset after replaceAll
1282
+ }
1283
+ }
1284
+
1285
+ process.stdout.write(` ${c.cyan('▶')} ${c.bold(`[${taskRecord.id}]`)} ${taskRecord.agent}${worktreePath ? c.dim(' (worktree)') : ''}\n`)
1286
+ events.emit(
1287
+ 'task_started',
1288
+ { worker_id: workerId },
1289
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1290
+ )
1291
+
1292
+ const taskStartTime = Date.now()
1293
+
1294
+ // ── Outbound prompt scan — NEVER send a prompt containing secrets ─────────
1295
+ const promptScan = scanForSecrets(taskRecord.prompt, `task:${taskRecord.id}`)
1296
+ if (!promptScan.clean) {
1297
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1298
+ finished_at: now(),
1299
+ output: `Secret detected in prompt — task blocked before execution.\nFindings:\n${
1300
+ promptScan.findings
1301
+ .map((f) => ` ${f.pattern} at line ${f.line}: ${f.snippet}`)
1302
+ .join('\n')
1303
+ }`,
1304
+ })
1305
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() })
1306
+ completedCount++
1307
+ events.emit(
1308
+ 'secret_leak_prevented',
1309
+ {
1310
+ task_id: taskRecord.id,
1311
+ findings_count: promptScan.findings.length,
1312
+ patterns: promptScan.findings.map((f) => f.pattern),
1313
+ },
1314
+ { convoy_id: convoyId, task_id: taskRecord.id },
1315
+ )
1316
+ cascadeFailure(taskRecord.id)
1317
+ taskAdapterMap.delete(taskRecord.id)
1318
+ return
1319
+ }
1320
+
1321
+ const timeout = makeTimeoutPromise(taskRecord.timeout_ms)
1322
+ let result: ExecuteResult
1323
+
1324
+ // Retrieve steps from spec if defined
1325
+ const specTask = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1326
+ const steps: TaskStep[] | undefined = specTask?.steps
1327
+ const taskHooks: Hook[] = specTask?.hooks ?? []
1328
+
1329
+ // ── Intelligence: inject lessons (Phase 18.1) ─────────────────────────
1330
+ if (spec.defaults?.inject_lessons !== false) {
1331
+ try {
1332
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
1333
+ const lessons = readLessons(taskRecord.agent, taskFiles, basePath)
1334
+ if (lessons.length > 0) {
1335
+ const lessonsBlock
1336
+ = '\n\n---\nRelevant lessons from previous sessions:\n'
1337
+ + lessons.join('\n\n')
1338
+ + '\n---\n\n'
1339
+ task.prompt = lessonsBlock + task.prompt
1340
+ }
1341
+ } catch { /* non-critical */ }
1342
+ }
1343
+ // ── Intelligence: inject persistent agent identity (Phase 17.2) ────────
1344
+ const specTaskForPersistent = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1345
+ if (specTaskForPersistent?.persistent) {
1346
+ try {
1347
+ const identities = store.getAgentIdentities(taskRecord.agent, 3)
1348
+ if (identities.length > 0) {
1349
+ const contextBlock = '\n\n[Previous work context]\n'
1350
+ + identities.map(id => id.summary).join('\n\n')
1351
+ + '\n[End previous context]\n\n'
1352
+ task.prompt = contextBlock + task.prompt
1353
+ }
1354
+ } catch { /* non-critical */ }
1355
+ }
1356
+ // ── Intelligence: inject discovered issues instruction (Phase 18.4) ────
1357
+ if (spec.defaults?.track_discovered_issues) {
1358
+ task.prompt = injectDiscoveredIssuesInstruction(task.prompt)
1359
+ }
1360
+
1361
+ // ── pre_task hooks ────────────────────────────────────────────────────────
1362
+ if (taskHooks.length > 0) {
1363
+ const preResult = await runHooks(taskHooks, 'pre_task', {
1364
+ taskId: taskRecord.id,
1365
+ convoyId,
1366
+ cwd: worktreePath ?? basePath,
1367
+ })
1368
+ if (!preResult.passed) {
1369
+ await removeWorktree()
1370
+ const hookLabel = preResult.failedHook?.name ?? preResult.failedHook?.type ?? 'unknown'
1371
+ store.withTransaction(() => {
1372
+ store.updateTaskStatus(taskRecord.id, convoyId, 'hook-failed', {
1373
+ finished_at: now(),
1374
+ output: `pre_task hook "${hookLabel}" failed: ${preResult.error ?? ''}`,
1375
+ exit_code: 1,
1376
+ })
1377
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() })
1378
+ })
1379
+ completedCount++
1380
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} pre_task hook failed ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
1381
+ events.emit('task_failed', { reason: 'hook-failed', hook: hookLabel, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
1382
+ cascadeFailure(taskRecord.id)
1383
+ taskAdapterMap.delete(taskRecord.id)
1384
+ return
1385
+ }
1386
+ }
1387
+
1388
+ // ── Symlink security scan (pre-execution) ────────────────────────────────
1389
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) as string[] : []
1390
+ if (taskFiles.length > 0 && worktreePath) {
1391
+ try {
1392
+ scanSymlinks(taskFiles, worktreePath)
1393
+ } catch (err) {
1394
+ await removeWorktree()
1395
+ store.withTransaction(() => {
1396
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1397
+ finished_at: now(),
1398
+ output: `Symlink security check failed: ${(err as Error).message}`,
1399
+ exit_code: 1,
1400
+ })
1401
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() })
1402
+ })
1403
+ completedCount++
1404
+ events.emit('task_failed', { reason: 'symlink-escape', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
1405
+ cascadeFailure(taskRecord.id)
1406
+ taskAdapterMap.delete(taskRecord.id)
1407
+ return
1408
+ }
1409
+ }
218
1410
 
219
- const taskStartTime = Date.now()
220
- const timeout = makeTimeoutPromise(taskRecord.timeout_ms)
221
- let result: ExecuteResult
222
1411
  try {
223
- result = await Promise.race([
224
- taskAdapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
225
- timeout.promise,
226
- ])
1412
+ if (steps && steps.length > 0) {
1413
+ result = await Promise.race([
1414
+ executeSteps(taskRecord, steps, taskAdapter, worktreePath, basePath, store, convoyId, verbose),
1415
+ timeout.promise,
1416
+ ])
1417
+ } else {
1418
+ result = await Promise.race([
1419
+ taskAdapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
1420
+ timeout.promise,
1421
+ ])
1422
+ }
227
1423
  timeout.clear()
228
1424
  } catch (err) {
229
1425
  timeout.clear()
@@ -247,12 +1443,14 @@ async function runConvoy(
247
1443
 
248
1444
  const freshRecord = store.getTask(taskRecord.id, convoyId)!
249
1445
  if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1446
+ const contextPrefix = `Previous attempt timed out.\n\nFix the issues and try again.\n\n`
250
1447
  store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
251
1448
  retries: freshRecord.retries + 1,
252
1449
  worker_id: null,
253
1450
  worktree: null,
254
1451
  started_at: null,
255
1452
  finished_at: null,
1453
+ prompt: contextPrefix + taskRecord.prompt,
256
1454
  })
257
1455
  store.updateWorkerStatus(workerId, 'killed', { finished_at: finishedAt })
258
1456
  process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} timed out, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
@@ -292,25 +1490,727 @@ async function runConvoy(
292
1490
  phase: taskRecord.phase,
293
1491
  convoy_id: convoyId,
294
1492
  }, { convoy_id: convoyId, task_id: taskRecord.id })
295
- cascadeFailure(taskRecord.id)
1493
+ handleExhaustion(freshRecord, 'timeout', result.output || null)
296
1494
  }
297
1495
  taskAdapterMap.delete(taskRecord.id)
298
1496
  return
299
1497
  }
300
1498
 
301
1499
  // ── Success ─────────────────────────────────────────────────────────────
302
- if (result.success) {
303
- if (worktreePath) {
304
- try {
305
- await mergeQueue.merge(worktreePath, `convoy-${workerId}`, baseBranch)
306
- } catch (err) {
307
- if (verbose) {
1500
+ if (result.success) { // ── Per-task gates ─────────────────────────────────────────────────────
1501
+ const taskGates = taskRecord.gates ? (JSON.parse(taskRecord.gates) as string[]) : []
1502
+ if (taskGates.length > 0) {
1503
+ let gateFailure: { command: string; exitCode: number; output: string } | null = null
1504
+ for (const command of taskGates) {
1505
+ try {
1506
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
1507
+ // They are NOT user-supplied and are part of the trusted build configuration.
1508
+ await execFile('sh', ['-c', command], { cwd: worktreePath ?? basePath })
1509
+ } catch (err) {
1510
+ const execErr = err as Error & { code?: unknown; stderr?: string; stdout?: string }
1511
+ const code = typeof execErr.code === 'number' ? execErr.code : 1
1512
+ const output = execErr.stderr || execErr.stdout || execErr.message || ''
1513
+ gateFailure = { command, exitCode: code, output }
1514
+ break
1515
+ }
1516
+ }
1517
+
1518
+ if (gateFailure !== null) {
1519
+ await removeWorktree()
1520
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1521
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1522
+ const contextPrefix = `Previous attempt's gate check failed.\nGate: ${gateFailure.command}\nExit code: ${gateFailure.exitCode}\nOutput:\n${gateFailure.output || '(no output)'}\n\nFix the issues and try again.\n\n`
1523
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1524
+ retries: freshRecord.retries + 1,
1525
+ worker_id: null,
1526
+ worktree: null,
1527
+ started_at: null,
1528
+ finished_at: null,
1529
+ prompt: contextPrefix + taskRecord.prompt,
1530
+ })
1531
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1532
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
1533
+ } else {
1534
+ store.withTransaction(() => {
1535
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1536
+ finished_at: finishedAt,
1537
+ output: `Gate failed: ${gateFailure!.command}\nExit code: ${gateFailure!.exitCode}\n${gateFailure!.output}`,
1538
+ exit_code: gateFailure!.exitCode,
1539
+ })
1540
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1541
+ })
1542
+ completedCount++
1543
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
1544
+ events.emit(
1545
+ 'task_failed',
1546
+ { reason: 'gate-failed', gate: gateFailure.command, exit_code: gateFailure.exitCode, worker_id: workerId },
1547
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1548
+ )
1549
+ events.emit('session', {
1550
+ agent: taskRecord.agent,
1551
+ model: taskRecord.model ?? taskAdapter.name,
1552
+ task: taskRecord.id,
1553
+ outcome: 'failed',
1554
+ duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
1555
+ files_changed: 0,
1556
+ retries: freshRecord.retries,
1557
+ convoy_id: convoyId,
1558
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1559
+ events.emit('delegation', {
1560
+ session_id: convoyId,
1561
+ agent: taskRecord.agent,
1562
+ model: taskRecord.model ?? taskAdapter.name,
1563
+ tier: 'standard',
1564
+ mechanism: 'convoy',
1565
+ outcome: 'failed',
1566
+ retries: freshRecord.retries,
1567
+ phase: taskRecord.phase,
1568
+ convoy_id: convoyId,
1569
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1570
+ handleExhaustion(freshRecord, 'gate-failed', gateFailure!.output || null)
1571
+ }
1572
+ taskAdapterMap.delete(taskRecord.id)
1573
+ return
1574
+ }
1575
+ }
1576
+
1577
+ // ── Built-in gates ────────────────────────────────────────────────────
1578
+ const builtInGates = spec.defaults?.built_in_gates
1579
+ if (builtInGates && worktreePath) {
1580
+ if (builtInGates.browser_test) {
1581
+ const specTask = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1582
+ const taskBrowserConfig = specTask?.browser_test ?? spec.defaults?.browser_test
1583
+ if (!taskBrowserConfig) {
308
1584
  process.stderr.write(
309
- `Warning: merge failed for ${taskRecord.id}: ${(err as Error).message}\n`,
1585
+ `Warning: browser_test gate enabled but no browser_test config (urls) found — skipping\n`,
1586
+ )
1587
+ } else {
1588
+ const browserResult = await browserTestGate({
1589
+ mcpServers: spec.defaults?.mcp_servers ?? [],
1590
+ taskConfig: taskBrowserConfig,
1591
+ worktreePath,
1592
+ approvalTimeout: spec.defaults?.mcp_server_approval_timeout,
1593
+ })
1594
+ events.emit(
1595
+ 'built_in_gate_result',
1596
+ { gate: 'browser_test', passed: browserResult.passed, output: browserResult.output },
1597
+ { convoy_id: convoyId, task_id: taskRecord.id },
1598
+ )
1599
+ if (!browserResult.passed) {
1600
+ await removeWorktree()
1601
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1602
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1603
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1604
+ retries: freshRecord.retries + 1,
1605
+ worker_id: null,
1606
+ worktree: null,
1607
+ started_at: null,
1608
+ finished_at: null,
1609
+ })
1610
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1611
+ process.stdout.write(
1612
+ ` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} browser test gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
1613
+ )
1614
+ } else {
1615
+ store.withTransaction(() => {
1616
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1617
+ finished_at: finishedAt,
1618
+ output: `Built-in gate (browser_test) failed:\n${browserResult.output}`,
1619
+ exit_code: 1,
1620
+ })
1621
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1622
+ })
1623
+ completedCount++
1624
+ process.stdout.write(
1625
+ ` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} browser test gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`,
1626
+ )
1627
+ events.emit(
1628
+ 'task_failed',
1629
+ { reason: 'gate-failed', gate: 'browser_test', worker_id: workerId },
1630
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1631
+ )
1632
+ handleExhaustion(freshRecord, 'browser-test', browserResult.output)
1633
+ }
1634
+ taskAdapterMap.delete(taskRecord.id)
1635
+ return
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ let changedFiles: string[] = []
1641
+ let diff = ''
1642
+ try {
1643
+ const { stdout: filesOut } = await execFile(
1644
+ 'git', ['diff', '--name-only', `${baseBranch}..HEAD`],
1645
+ { cwd: worktreePath },
1646
+ )
1647
+ changedFiles = filesOut.split('\n').filter(Boolean)
1648
+ const { stdout: diffOut } = await execFile(
1649
+ 'git', ['diff', `${baseBranch}..HEAD`],
1650
+ { cwd: worktreePath },
1651
+ )
1652
+ diff = diffOut
1653
+ } catch { /* no commits in worktree yet — skip */ }
1654
+
1655
+ // Secret scan gate
1656
+ if (builtInGates.secret_scan && changedFiles.length > 0) {
1657
+ const scanResult = await runSecretScanGate(changedFiles, worktreePath)
1658
+ events.emit(
1659
+ 'built_in_gate_result',
1660
+ { gate: 'secret_scan', passed: scanResult.passed, output: scanResult.output },
1661
+ { convoy_id: convoyId, task_id: taskRecord.id },
1662
+ )
1663
+ if (!scanResult.passed) {
1664
+ await removeWorktree()
1665
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1666
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1667
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1668
+ retries: freshRecord.retries + 1,
1669
+ worker_id: null,
1670
+ worktree: null,
1671
+ started_at: null,
1672
+ finished_at: null,
1673
+ prompt: `Secret scan gate failed.\n${scanResult.output}\n\nFix the issues and try again.\n\n${taskRecord.prompt}`,
1674
+ })
1675
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1676
+ process.stdout.write(
1677
+ ` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} secret scan gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
1678
+ )
1679
+ } else {
1680
+ store.withTransaction(() => {
1681
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1682
+ finished_at: finishedAt,
1683
+ output: `Built-in gate (secret_scan) failed:\n${scanResult.output}`,
1684
+ exit_code: 1,
1685
+ })
1686
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1687
+ })
1688
+ completedCount++
1689
+ process.stdout.write(
1690
+ ` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} secret scan gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`,
1691
+ )
1692
+ events.emit(
1693
+ 'task_failed',
1694
+ { reason: 'gate-failed', gate: 'secret_scan', worker_id: workerId },
1695
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1696
+ )
1697
+ handleExhaustion(freshRecord, 'secret-scan', scanResult.output)
1698
+ }
1699
+ taskAdapterMap.delete(taskRecord.id)
1700
+ return
1701
+ }
1702
+ }
1703
+
1704
+ // Blast radius gate
1705
+ if (builtInGates.blast_radius && diff) {
1706
+ const blastResult = runBlastRadiusGate(diff)
1707
+ events.emit(
1708
+ 'built_in_gate_result',
1709
+ { gate: 'blast_radius', level: blastResult.level, passed: blastResult.passed, output: blastResult.output },
1710
+ { convoy_id: convoyId, task_id: taskRecord.id },
1711
+ )
1712
+ if (!blastResult.passed) {
1713
+ await removeWorktree()
1714
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1715
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1716
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1717
+ retries: freshRecord.retries + 1,
1718
+ worker_id: null,
1719
+ worktree: null,
1720
+ started_at: null,
1721
+ finished_at: null,
1722
+ prompt: `Blast radius gate failed.\n${blastResult.output}\n\nFix the issues and try again.\n\n${taskRecord.prompt}`,
1723
+ })
1724
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1725
+ process.stdout.write(
1726
+ ` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} blast radius gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`,
1727
+ )
1728
+ } else {
1729
+ store.withTransaction(() => {
1730
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1731
+ finished_at: finishedAt,
1732
+ output: `Built-in gate (blast_radius) failed:\n${blastResult.output}`,
1733
+ exit_code: 1,
1734
+ })
1735
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1736
+ })
1737
+ completedCount++
1738
+ process.stdout.write(
1739
+ ` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} blast radius gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`,
1740
+ )
1741
+ events.emit(
1742
+ 'task_failed',
1743
+ { reason: 'gate-failed', gate: 'blast_radius', worker_id: workerId },
1744
+ { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId },
1745
+ )
1746
+ handleExhaustion(freshRecord, 'gate-failed', blastResult.output)
1747
+ }
1748
+ taskAdapterMap.delete(taskRecord.id)
1749
+ return
1750
+ }
1751
+ }
1752
+ }
1753
+
1754
+ // ── Drift detection ──────────────────────────────────────────────────
1755
+ const specTaskForDrift = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1756
+ const isDriftEnabled = specTaskForDrift?.detect_drift ?? spec.defaults?.detect_drift ?? false
1757
+
1758
+ if (isDriftEnabled && taskRecord.drift_retried === 0) {
1759
+ const driftResult = await detectDrift(taskRecord, taskAdapter)
1760
+
1761
+ events.emit('drift_check_result', {
1762
+ task_id: taskRecord.id,
1763
+ score: driftResult.score,
1764
+ threshold: driftResult.threshold,
1765
+ explanation: driftResult.explanation,
1766
+ drifted: driftResult.drifted,
1767
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1768
+
1769
+ store.updateTaskDrift(taskRecord.id, convoyId, { drift_score: driftResult.score })
1770
+
1771
+ if (driftResult.drifted) {
1772
+ events.emit('drift_detected', {
1773
+ task_id: taskRecord.id,
1774
+ score: driftResult.score,
1775
+ threshold: driftResult.threshold,
1776
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1777
+
1778
+ await removeWorktree()
1779
+ store.updateTaskDrift(taskRecord.id, convoyId, { drift_retried: 1 })
1780
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1781
+ worker_id: null,
1782
+ worktree: null,
1783
+ started_at: null,
1784
+ finished_at: null,
1785
+ })
1786
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1787
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} drift detected (score: ${driftResult.score.toFixed(2)}), retrying\n`)
1788
+ taskAdapterMap.delete(taskRecord.id)
1789
+ return
1790
+ }
1791
+ }
1792
+
1793
+ // ── Review pipeline ──────────────────────────────────────────────────
1794
+ const specTaskForReview = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
1795
+ const taskReviewSetting: string = specTaskForReview?.review ?? spec.defaults?.review ?? 'auto'
1796
+
1797
+ if (taskReviewSetting !== 'none') {
1798
+ // Compute diff stats from worktree
1799
+ let reviewChangedFiles: string[] = []
1800
+ let reviewDiffLines = 0
1801
+
1802
+ if (worktreePath) {
1803
+ try {
1804
+ const { stdout: filesOut } = await execFile(
1805
+ 'git', ['diff', '--name-only', `${baseBranch}..HEAD`],
1806
+ { cwd: worktreePath },
1807
+ )
1808
+ reviewChangedFiles = filesOut.split('\n').filter(Boolean)
1809
+ const { stdout: diffOut } = await execFile(
1810
+ 'git', ['diff', `${baseBranch}..HEAD`],
1811
+ { cwd: worktreePath },
310
1812
  )
1813
+ reviewDiffLines = diffOut.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')).filter(l => !l.startsWith('+++') && !l.startsWith('---')).length
1814
+ } catch { /* no commits yet */ }
1815
+ }
1816
+
1817
+ const diffStats: DiffStats = {
1818
+ linesChanged: reviewDiffLines,
1819
+ filesChanged: reviewChangedFiles.length,
1820
+ filePaths: reviewChangedFiles,
1821
+ }
1822
+
1823
+ // Determine review level
1824
+ let reviewLevel: ReviewLevel
1825
+ if (taskReviewSetting === 'fast') {
1826
+ reviewLevel = 'fast'
1827
+ } else if (taskReviewSetting === 'panel') {
1828
+ reviewLevel = 'panel'
1829
+ } else {
1830
+ reviewLevel = evaluateReviewLevel(taskRecord, diffStats, spec.defaults?.review_heuristics, true)
1831
+ }
1832
+
1833
+ const reviewerModel = spec.defaults?.reviewer_model ?? 'default'
1834
+ events.emit('review_started', { level: reviewLevel, task_id: taskRecord.id, model: reviewerModel }, { convoy_id: convoyId, task_id: taskRecord.id })
1835
+
1836
+ if (reviewLevel === 'auto-pass') {
1837
+ store.updateTaskReview(taskRecord.id, convoyId, {
1838
+ review_level: 'auto-pass',
1839
+ review_verdict: 'pass',
1840
+ review_tokens: 0,
1841
+ review_model: reviewerModel,
1842
+ })
1843
+ events.emit('review_verdict', { level: 'auto-pass', verdict: 'pass', tokens: 0, model: reviewerModel, feedback_length: 0 }, { convoy_id: convoyId, task_id: taskRecord.id })
1844
+ } else if (reviewLevel === 'fast') {
1845
+ // Check review budget
1846
+ const reviewBudget = spec.defaults?.review_budget
1847
+ const onBudgetExceeded = spec.defaults?.on_review_budget_exceeded ?? 'skip'
1848
+
1849
+ if (reviewBudget != null && reviewTokensTotal >= reviewBudget) {
1850
+ if (onBudgetExceeded === 'stop') {
1851
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending')
1852
+ for (const t of allPending) skipTask(t.id, 'review_budget exceeded with on_review_budget_exceeded: stop')
1853
+ store.withTransaction(() => {
1854
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', { finished_at: finishedAt, output: 'Review budget exceeded', exit_code: 1 })
1855
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1856
+ })
1857
+ completedCount++
1858
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} review budget exceeded (stop) ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
1859
+ events.emit('review_verdict', { level: 'fast', verdict: 'skip', tokens: 0, model: reviewerModel, feedback_length: 0, budget_exceeded: true }, { convoy_id: convoyId, task_id: taskRecord.id })
1860
+ taskAdapterMap.delete(taskRecord.id)
1861
+ return
1862
+ } else if (onBudgetExceeded === 'downgrade') {
1863
+ store.updateTaskReview(taskRecord.id, convoyId, { review_level: 'fast', review_verdict: 'pass', review_tokens: 0, review_model: reviewerModel })
1864
+ events.emit('review_verdict', { level: 'fast', verdict: 'pass', tokens: 0, model: reviewerModel, feedback_length: 0, budget_downgrade: true }, { convoy_id: convoyId, task_id: taskRecord.id })
1865
+ } else {
1866
+ // 'skip': treat as passed
1867
+ events.emit('review_verdict', { level: 'fast', verdict: 'pass', tokens: 0, model: reviewerModel, feedback_length: 0, budget_skip: true }, { convoy_id: convoyId, task_id: taskRecord.id })
1868
+ }
1869
+ } else {
1870
+ await reviewSemaphore.acquire()
1871
+ let reviewResult: ReviewResult
1872
+ try {
1873
+ if (reviewRunner) {
1874
+ reviewResult = await reviewRunner(taskRecord, 'fast', reviewerModel)
1875
+ } else {
1876
+ reviewResult = { verdict: 'pass', feedback: '', tokens: 0, model: reviewerModel }
1877
+ }
1878
+ } finally {
1879
+ reviewSemaphore.release()
1880
+ }
1881
+
1882
+ reviewTokensTotal += reviewResult.tokens
1883
+ store.updateTaskReview(taskRecord.id, convoyId, {
1884
+ review_level: 'fast',
1885
+ review_verdict: reviewResult.verdict,
1886
+ review_tokens: reviewResult.tokens,
1887
+ review_model: reviewResult.model,
1888
+ })
1889
+ store.updateConvoyReviewTokens(convoyId, reviewTokensTotal)
1890
+ events.emit('review_verdict', { level: 'fast', verdict: reviewResult.verdict, tokens: reviewResult.tokens, model: reviewResult.model, feedback_length: reviewResult.feedback.length }, { convoy_id: convoyId, task_id: taskRecord.id })
1891
+
1892
+ if (reviewResult.verdict === 'block') {
1893
+ await removeWorktree()
1894
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1895
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1896
+ const contextPrefix = `Previous attempt was blocked by review.\nFeedback:\n${reviewResult.feedback}\n\nFix the issues and try again.\n\n`
1897
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1898
+ retries: freshRecord.retries + 1,
1899
+ worker_id: null,
1900
+ worktree: null,
1901
+ started_at: null,
1902
+ finished_at: null,
1903
+ prompt: contextPrefix + taskRecord.prompt,
1904
+ })
1905
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1906
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} review blocked, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
1907
+ taskAdapterMap.delete(taskRecord.id)
1908
+ return
1909
+ } else {
1910
+ store.withTransaction(() => {
1911
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', {
1912
+ finished_at: finishedAt,
1913
+ output: `Review blocked: ${reviewResult.feedback}`,
1914
+ exit_code: 1,
1915
+ })
1916
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
1917
+ })
1918
+ completedCount++
1919
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} review blocked ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
1920
+ events.emit('task_failed', { reason: 'review-blocked', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
1921
+ handleExhaustion(freshRecord, 'review-blocked', reviewResult.feedback || null)
1922
+ taskAdapterMap.delete(taskRecord.id)
1923
+ return
1924
+ }
1925
+ }
1926
+ }
1927
+ } else {
1928
+ // panel: 3 concurrent reviewer calls, majority vote
1929
+ await reviewSemaphore.acquire()
1930
+ let panelResults: ReviewResult[]
1931
+ try {
1932
+ const noopRunner = (_t: TaskRecord, _l: ReviewLevel, m: string) => Promise.resolve({ verdict: 'pass' as const, feedback: '', tokens: 0, model: m })
1933
+ const runner = reviewRunner ?? noopRunner
1934
+ panelResults = await Promise.all([
1935
+ runner(taskRecord, 'panel', reviewerModel),
1936
+ runner(taskRecord, 'panel', reviewerModel),
1937
+ runner(taskRecord, 'panel', reviewerModel),
1938
+ ])
1939
+ } finally {
1940
+ reviewSemaphore.release()
1941
+ }
1942
+
1943
+ const panelPasses = panelResults.filter(r => r.verdict === 'pass').length
1944
+ const panelBlocks = panelResults.filter(r => r.verdict === 'block').length
1945
+ const totalPanelTokens = panelResults.reduce((sum, r) => sum + r.tokens, 0)
1946
+ reviewTokensTotal += totalPanelTokens
1947
+
1948
+ const freshForPanel = store.getTask(taskRecord.id, convoyId)!
1949
+ store.updateTaskReview(taskRecord.id, convoyId, {
1950
+ review_level: 'panel',
1951
+ review_verdict: panelPasses >= 2 ? 'pass' : 'block',
1952
+ review_tokens: totalPanelTokens,
1953
+ review_model: reviewerModel,
1954
+ panel_attempts: freshForPanel.panel_attempts + 1,
1955
+ })
1956
+ if (totalPanelTokens > 0) store.updateConvoyReviewTokens(convoyId, reviewTokensTotal)
1957
+ events.emit('review_verdict', { level: 'panel', verdict: panelPasses >= 2 ? 'pass' : 'block', tokens: totalPanelTokens, model: reviewerModel, feedback_length: panelResults.map(r => r.feedback).join('').length, passes: panelPasses, blocks: panelBlocks }, { convoy_id: convoyId, task_id: taskRecord.id })
1958
+
1959
+ if (panelBlocks >= 2) {
1960
+ const blockFeedback = panelResults.filter(r => r.verdict === 'block').map(r => r.feedback).join('\n\n---\n\n')
1961
+ await removeWorktree()
1962
+
1963
+ // Check for dispute trigger
1964
+ const updatedTask = store.getTask(taskRecord.id, convoyId)!
1965
+ if (updatedTask.panel_attempts >= 3) {
1966
+ const disputeId = `dispute-${taskRecord.id}-${Date.now()}`
1967
+ const onDispute = spec.defaults?.on_dispute ?? 'stop'
1968
+
1969
+ store.updateTaskDisputeStatus(taskRecord.id, convoyId, 'disputed', disputeId)
1970
+ writeDisputeToMarkdown(disputeId, convoyId, taskRecord, panelResults, events)
1971
+
1972
+ events.emit('dispute_opened', {
1973
+ dispute_id: disputeId,
1974
+ task_id: taskRecord.id,
1975
+ agent: taskRecord.agent,
1976
+ panel_attempts: updatedTask.panel_attempts,
1977
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
1978
+
1979
+ if (onDispute === 'stop') {
1980
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending')
1981
+ for (const t of allPending) {
1982
+ skipTask(t.id, `on_dispute: stop — task "${taskRecord.id}" disputed`)
1983
+ }
1984
+ }
1985
+
1986
+ completedCount++
1987
+ process.stdout.write(` ${c.red('⚡')} ${c.bold(`[${taskRecord.id}]`)} disputed after ${updatedTask.panel_attempts} panel attempts\n`)
1988
+ taskAdapterMap.delete(taskRecord.id)
1989
+ return
1990
+ }
1991
+
1992
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
1993
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1994
+ const contextPrefix = `Previous attempt was blocked by panel review (${panelBlocks}/3 reviewers).\nMUST-FIX:\n${blockFeedback}\n\nFix the issues and try again.\n\n`
1995
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1996
+ retries: freshRecord.retries + 1,
1997
+ worker_id: null,
1998
+ worktree: null,
1999
+ started_at: null,
2000
+ finished_at: null,
2001
+ prompt: contextPrefix + taskRecord.prompt,
2002
+ })
2003
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2004
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} panel blocked (${panelBlocks}/3), retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
2005
+ taskAdapterMap.delete(taskRecord.id)
2006
+ return
2007
+ } else {
2008
+ store.withTransaction(() => {
2009
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', {
2010
+ finished_at: finishedAt,
2011
+ output: `Panel review blocked (${panelBlocks}/3): ${blockFeedback}`,
2012
+ exit_code: 1,
2013
+ })
2014
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2015
+ })
2016
+ completedCount++
2017
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} panel blocked ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
2018
+ events.emit('task_failed', { reason: 'review-blocked', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
2019
+ handleExhaustion(freshRecord, 'review-blocked', blockFeedback || null)
2020
+ taskAdapterMap.delete(taskRecord.id)
2021
+ return
2022
+ }
2023
+ }
2024
+ }
2025
+ }
2026
+
2027
+ // ── Intelligence: check discovered issues (Phase 18.4) ─────────────
2028
+ if (spec.defaults?.track_discovered_issues) {
2029
+ try {
2030
+ checkDiscoveredIssues(taskRecord.id, events, convoyId, worktreePath ?? basePath)
2031
+ } catch { /* non-critical */ }
2032
+ }
2033
+
2034
+ // ── post_task hooks ───────────────────────────────────────────────────
2035
+ if (taskHooks.length > 0) {
2036
+ const postResult = await runHooks(taskHooks, 'post_task', {
2037
+ taskId: taskRecord.id,
2038
+ convoyId,
2039
+ cwd: worktreePath ?? basePath,
2040
+ })
2041
+ if (!postResult.passed) {
2042
+ await removeWorktree()
2043
+ const hookLabel = postResult.failedHook?.name ?? postResult.failedHook?.type ?? 'unknown'
2044
+ store.withTransaction(() => {
2045
+ store.updateTaskStatus(taskRecord.id, convoyId, 'hook-failed', {
2046
+ finished_at: finishedAt,
2047
+ output: `post_task hook "${hookLabel}" failed: ${postResult.error ?? ''}`,
2048
+ exit_code: 1,
2049
+ })
2050
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2051
+ })
2052
+ completedCount++
2053
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} post_task hook failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
2054
+ events.emit('task_failed', { reason: 'hook-failed', hook: hookLabel, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
2055
+ cascadeFailure(taskRecord.id)
2056
+ taskAdapterMap.delete(taskRecord.id)
2057
+ return
2058
+ }
2059
+ }
2060
+
2061
+ // ── Symlink security scan (post-execution) ───────────────────────────
2062
+ if (taskFiles.length > 0 && worktreePath) {
2063
+ try {
2064
+ scanNewSymlinks(worktreePath, taskFiles)
2065
+ } catch (err) {
2066
+ await removeWorktree()
2067
+ store.withTransaction(() => {
2068
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
2069
+ finished_at: finishedAt,
2070
+ output: `Post-execution symlink security check failed: ${(err as Error).message}`,
2071
+ exit_code: 1,
2072
+ })
2073
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
2074
+ })
2075
+ completedCount++
2076
+ events.emit('task_failed', { reason: 'symlink-escape-post', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
2077
+ cascadeFailure(taskRecord.id)
2078
+ taskAdapterMap.delete(taskRecord.id)
2079
+ return
2080
+ }
2081
+ }
2082
+
2083
+ if (worktreePath) {
2084
+ let mergeAttempt = 0
2085
+ const maxMergeAttempts = 2
2086
+ let merged = false
2087
+
2088
+ while (mergeAttempt < maxMergeAttempts && !merged) {
2089
+ try {
2090
+ await mergeQueue.merge(worktreePath, `convoy-${workerId}`, baseBranch)
2091
+ merged = true
2092
+ } catch (err) {
2093
+ if (err instanceof MergeConflictError) {
2094
+ mergeAttempt++
2095
+ events.emit('merge_conflict_detected', {
2096
+ attempt: mergeAttempt,
2097
+ conflicting_files: err.conflictingFiles,
2098
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2099
+
2100
+ if (mergeAttempt >= maxMergeAttempts) {
2101
+ events.emit('merge_conflict_failed', {
2102
+ attempts: mergeAttempt,
2103
+ conflicting_files: err.conflictingFiles,
2104
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2105
+
2106
+ const freshRecord = store.getTask(taskRecord.id, convoyId)!
2107
+ store.withTransaction(() => {
2108
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
2109
+ finished_at: now(),
2110
+ output: `Merge conflict could not be resolved after ${mergeAttempt} attempts. Files: ${err.conflictingFiles.join(', ')}`,
2111
+ exit_code: 1,
2112
+ })
2113
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() })
2114
+ })
2115
+ completedCount++
2116
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} merge conflict unresolved ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
2117
+ events.emit('task_failed', { reason: 'merge-conflict', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId })
2118
+ cascadeFailure(taskRecord.id)
2119
+ handleExhaustion(freshRecord, 'merge-conflict', err.conflictingFiles.join(', '))
2120
+ break
2121
+ }
2122
+
2123
+ // Per spec: backoff on second attempt (unreachable with maxMergeAttempts=2 but follows spec)
2124
+ if (mergeAttempt === 2) {
2125
+ await new Promise<void>(resolve => setTimeout(resolve, 30_000))
2126
+ }
2127
+
2128
+ // Inject a resolution task
2129
+ const fileHash = createHash('sha256')
2130
+ .update(err.conflictingFiles.sort().join(','))
2131
+ .digest('hex')
2132
+ .slice(0, 12)
2133
+ const idempotencyKey = `merge-conflict:${taskRecord.phase}:${fileHash}`
2134
+ const resolutionTaskId = `merge-fix-${taskRecord.id}-${mergeAttempt}`
2135
+ const conflictPrompt = `Resolve merge conflicts in: ${err.conflictingFiles.join(', ')}. Ensure no conflict markers remain (<<<<<<<, =======, >>>>>>>), syntax is valid, no duplicate imports.`
2136
+
2137
+ const resolutionRecord: TaskRecord = {
2138
+ id: resolutionTaskId,
2139
+ convoy_id: convoyId,
2140
+ phase: taskRecord.phase,
2141
+ prompt: conflictPrompt,
2142
+ agent: taskRecord.agent,
2143
+ adapter: null,
2144
+ model: null,
2145
+ timeout_ms: 600_000,
2146
+ status: 'pending',
2147
+ worker_id: null,
2148
+ worktree: null,
2149
+ output: null,
2150
+ exit_code: null,
2151
+ started_at: null,
2152
+ finished_at: null,
2153
+ retries: 0,
2154
+ max_retries: 1,
2155
+ files: JSON.stringify(err.conflictingFiles),
2156
+ depends_on: null,
2157
+ prompt_tokens: null,
2158
+ completion_tokens: null,
2159
+ total_tokens: null,
2160
+ cost_usd: null,
2161
+ gates: null,
2162
+ on_exhausted: 'dlq',
2163
+ injected: 1,
2164
+ provenance: 'merge-conflict',
2165
+ idempotency_key: idempotencyKey,
2166
+ current_step: null,
2167
+ total_steps: null,
2168
+ review_level: null,
2169
+ review_verdict: null,
2170
+ review_tokens: null,
2171
+ review_model: null,
2172
+ panel_attempts: 0,
2173
+ dispute_id: null,
2174
+ drift_score: null,
2175
+ drift_retried: 0,
2176
+ outputs: null,
2177
+ inputs: null,
2178
+ discovered_issues: null,
2179
+ }
2180
+
2181
+ store.insertInjectedTask(resolutionRecord)
2182
+ const storedResolutionRecord = store.getTask(resolutionTaskId, convoyId)!
2183
+ await executeOneTask(storedResolutionRecord)
2184
+ // Next loop iteration will retry the merge
2185
+ } else {
2186
+ // Non-conflict merge error — log warning and continue to done path
2187
+ if (verbose) {
2188
+ process.stderr.write(
2189
+ `Warning: merge failed for ${taskRecord.id}: ${(err as Error).message}\n`,
2190
+ )
2191
+ }
2192
+ merged = true // Preserve original behavior: continue despite error
2193
+ break
2194
+ }
311
2195
  }
312
2196
  }
2197
+
313
2198
  await removeWorktree()
2199
+
2200
+ if (!merged) {
2201
+ taskAdapterMap.delete(taskRecord.id)
2202
+ return
2203
+ }
2204
+
2205
+ // ── Intelligence: update expertise post-merge (Phase 18.2) ─────────
2206
+ try {
2207
+ updateExpertise(taskRecord.agent, { taskId: taskRecord.id, success: true, retries: taskRecord.retries, files: taskRecord.files ? JSON.parse(taskRecord.files) as string[] : [] }, basePath)
2208
+ } catch { /* non-critical */ }
2209
+ // ── Intelligence: build knowledge graph post-merge (Phase 18.3) ────
2210
+ try {
2211
+ const { stdout: diffOut } = await execFile('git', ['diff', 'HEAD~1'], { cwd: basePath })
2212
+ buildKnowledgeGraph(diffOut, convoyId, basePath)
2213
+ } catch { /* non-critical */ }
314
2214
  }
315
2215
 
316
2216
  const usageExtra: Partial<{ prompt_tokens: number; completion_tokens: number; total_tokens: number }> = {}
@@ -320,6 +2220,77 @@ async function runConvoy(
320
2220
  if (result.usage.total_tokens != null) usageExtra.total_tokens = result.usage.total_tokens
321
2221
  }
322
2222
 
2223
+ // ── Capture outputs as artifacts ────────────────────────────────────────
2224
+ if (taskRecord.outputs) {
2225
+ const outputs: TaskOutput[] = JSON.parse(taskRecord.outputs)
2226
+ for (const output of outputs) {
2227
+ let content: string
2228
+ if (output.type === 'summary') {
2229
+ content = result.output.slice(-4096)
2230
+ } else if (output.type === 'json') {
2231
+ const jsonMatch = result.output.match(/```json\n([\s\S]*?)```/)
2232
+ content = jsonMatch ? jsonMatch[1].trim() : result.output
2233
+ } else {
2234
+ content = result.output
2235
+ }
2236
+ try {
2237
+ store.insertArtifact({
2238
+ id: `artifact-${taskRecord.id}-${output.name}-${Date.now()}`,
2239
+ convoy_id: convoyId,
2240
+ task_id: taskRecord.id,
2241
+ name: output.name,
2242
+ type: output.type,
2243
+ content,
2244
+ created_at: new Date().toISOString(),
2245
+ })
2246
+ } catch (err) {
2247
+ if (err instanceof ConvoyArtifactLimitError) {
2248
+ events.emit('artifact_limit_reached', {
2249
+ task_id: taskRecord.id,
2250
+ artifact_name: output.name,
2251
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2252
+ } else {
2253
+ throw err
2254
+ }
2255
+ }
2256
+ }
2257
+ }
2258
+
2259
+ // ── Intelligence: capture persistent agent identity (Phase 17.2) ─────
2260
+ const specTaskForCapture = (spec.tasks ?? []).find(t => t.id === taskRecord.id)
2261
+ if (specTaskForCapture?.persistent && result.output) {
2262
+ try {
2263
+ // Extract last 300 words, cap at 4KB
2264
+ const words = result.output.split(/\s+/)
2265
+ const lastWords = words.slice(-300).join(' ')
2266
+ let summary = lastWords.length > 4096 ? lastWords.slice(-4096) : lastWords
2267
+
2268
+ // Secret-scan the summary before storing
2269
+ const summaryScan = scanForSecrets(summary, `identity:${taskRecord.id}`)
2270
+ if (summaryScan.clean) {
2271
+ store.insertAgentIdentity({
2272
+ id: `identity-${taskRecord.id}-${Date.now()}`,
2273
+ agent: taskRecord.agent,
2274
+ convoy_id: convoyId,
2275
+ task_id: taskRecord.id,
2276
+ summary,
2277
+ created_at: new Date().toISOString(),
2278
+ retention_days: 90,
2279
+ })
2280
+ events.emit('agent_identity_captured', {
2281
+ agent: taskRecord.agent,
2282
+ summary_length: summary.length,
2283
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2284
+ } else {
2285
+ events.emit('agent_identity_rejected', {
2286
+ agent: taskRecord.agent,
2287
+ reason: 'secrets_detected',
2288
+ findings_count: summaryScan.findings.length,
2289
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2290
+ }
2291
+ } catch { /* non-critical */ }
2292
+ }
2293
+
323
2294
  store.withTransaction(() => {
324
2295
  store.updateTaskStatus(taskRecord.id, convoyId, 'done', {
325
2296
  finished_at: finishedAt,
@@ -329,6 +2300,24 @@ async function runConvoy(
329
2300
  })
330
2301
  store.updateWorkerStatus(workerId, 'done', { finished_at: finishedAt })
331
2302
  })
2303
+ // ── Circuit breaker: record success ────────────────────────────────────
2304
+ if (circuitBreakerConfig) {
2305
+ circuitBreaker.recordSuccess(taskRecord.agent)
2306
+ try { store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize()) } catch { /* non-critical */ }
2307
+ }
2308
+ // ── Intelligence: capture retry lesson (Phase 18.1) ─────────────────
2309
+ if (taskRecord.retries > 0 && spec.defaults?.inject_lessons !== false) {
2310
+ try {
2311
+ captureLessons({
2312
+ title: `Retry success for ${taskRecord.agent} on ${taskRecord.id}`,
2313
+ category: 'convoy',
2314
+ agent: taskRecord.agent,
2315
+ problem: `Task ${taskRecord.id} required ${taskRecord.retries} retries`,
2316
+ solution: 'Succeeded after retry with adjusted approach',
2317
+ files: taskRecord.files ? JSON.parse(taskRecord.files) as string[] : undefined,
2318
+ }, basePath)
2319
+ } catch { /* non-critical */ }
2320
+ }
332
2321
  completedCount++
333
2322
  process.stdout.write(` ${c.green('✓')} ${c.bold(`[${taskRecord.id}]`)} ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
334
2323
  events.emit(
@@ -367,12 +2356,15 @@ async function runConvoy(
367
2356
 
368
2357
  const freshRecord = store.getTask(taskRecord.id, convoyId)!
369
2358
  if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
2359
+ const failedOutput = result.output || '(no output)'
2360
+ const contextPrefix = `Previous attempt failed.\nExit code: ${result.exitCode}\nError output:\n${failedOutput}\n\nFix the issues and try again.\n\n`
370
2361
  store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
371
2362
  retries: freshRecord.retries + 1,
372
2363
  worker_id: null,
373
2364
  worktree: null,
374
2365
  started_at: null,
375
2366
  finished_at: null,
2367
+ prompt: contextPrefix + taskRecord.prompt,
376
2368
  })
377
2369
  store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
378
2370
  process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`)
@@ -385,6 +2377,21 @@ async function runConvoy(
385
2377
  })
386
2378
  store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt })
387
2379
  })
2380
+ // ── Intelligence: record failure in expertise (Phase 18.2) ──────────
2381
+ try {
2382
+ updateExpertise(taskRecord.agent, { taskId: taskRecord.id, success: false, retries: freshRecord.retries, files: taskRecord.files ? JSON.parse(taskRecord.files) as string[] : [] }, basePath)
2383
+ } catch { /* non-critical */ }
2384
+ // ── Circuit breaker: record failure ────────────────────────────────────
2385
+ if (circuitBreakerConfig) {
2386
+ const { tripped } = circuitBreaker.recordFailure(taskRecord.agent)
2387
+ try { store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize()) } catch { /* non-critical */ }
2388
+ if (tripped) {
2389
+ events.emit('circuit_breaker_tripped', {
2390
+ agent: taskRecord.agent,
2391
+ state: circuitBreaker.getState(taskRecord.agent),
2392
+ }, { convoy_id: convoyId, task_id: taskRecord.id })
2393
+ }
2394
+ }
388
2395
  completedCount++
389
2396
  process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`)
390
2397
  if (verbose) {
@@ -417,7 +2424,7 @@ async function runConvoy(
417
2424
  phase: taskRecord.phase,
418
2425
  convoy_id: convoyId,
419
2426
  }, { convoy_id: convoyId, task_id: taskRecord.id })
420
- cascadeFailure(taskRecord.id)
2427
+ handleExhaustion(freshRecord, 'error', result.output || null)
421
2428
  }
422
2429
  taskAdapterMap.delete(taskRecord.id)
423
2430
  }
@@ -425,20 +2432,47 @@ async function runConvoy(
425
2432
  // ── Main execution loop ───────────────────────────────────────────────────
426
2433
 
427
2434
  let lastPhase = -1
2435
+ const isSwarmMode = spec.concurrency === 'auto'
2436
+ const maxSwarmConcurrency = spec.defaults?.max_swarm_concurrency ?? 8
2437
+ let lastInjectPoll = 0
2438
+ const INJECT_POLL_INTERVAL = 2000 // 2 seconds
428
2439
  try {
429
2440
  let ready = store.getReadyTasks(convoyId)
430
- const concurrency = spec.concurrency ?? 1
431
2441
  while (ready.length > 0) {
2442
+ // Compute effective concurrency for this phase
2443
+ const effectiveConcurrency = isSwarmMode
2444
+ ? Math.min(ready.length, maxSwarmConcurrency)
2445
+ : (typeof spec.concurrency === 'number' ? spec.concurrency : 1)
2446
+
432
2447
  for (const t of ready) {
433
2448
  if (t.phase !== lastPhase) {
434
2449
  lastPhase = t.phase
435
2450
  const tasksInPhase = ready.filter(r => r.phase === t.phase)
436
2451
  const ids = tasksInPhase.map(r => r.id).join(', ')
437
2452
  process.stdout.write(`\n ${c.bold(`Phase ${t.phase + 1}:`)} ${c.dim(ids)}\n`)
2453
+ if (isSwarmMode) {
2454
+ events.emit('swarm_concurrency_update', {
2455
+ phase: t.phase,
2456
+ pending_count: ready.length,
2457
+ effective_concurrency: effectiveConcurrency,
2458
+ }, { convoy_id: convoyId })
2459
+ }
438
2460
  }
439
2461
  }
440
- for (let i = 0; i < ready.length; i += concurrency) {
441
- await Promise.all(ready.slice(i, i + concurrency).map(t => executeOneTask(t)))
2462
+ for (let i = 0; i < ready.length; i += effectiveConcurrency) {
2463
+ // Poll for file-based injection between batches
2464
+ const now = Date.now()
2465
+ if (now - lastInjectPoll >= INJECT_POLL_INTERVAL) {
2466
+ pollInjectFile(convoyId, store, events, basePath)
2467
+ lastInjectPoll = now
2468
+ }
2469
+ await Promise.all(ready.slice(i, i + effectiveConcurrency).map(t => executeOneTask(t)))
2470
+ }
2471
+ // Reset wait-for-input tasks to pending so they are re-evaluated after
2472
+ // upstream artifacts may have been captured in this batch
2473
+ const waitingTasks = store.getTasksByConvoy(convoyId).filter(t => t.status === ('wait-for-input' as ConvoyTaskStatus))
2474
+ for (const wt of waitingTasks) {
2475
+ store.updateTaskStatus(wt.id, convoyId, 'pending')
442
2476
  }
443
2477
  ready = store.getReadyTasks(convoyId)
444
2478
  }
@@ -460,6 +2494,8 @@ async function runConvoy(
460
2494
 
461
2495
  for (const command of spec.gates) {
462
2496
  try {
2497
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
2498
+ // They are NOT user-supplied and are part of the trusted build configuration.
463
2499
  await execFile('sh', ['-c', command], { cwd: basePath })
464
2500
  gateResults.push({ command, exitCode: 0, passed: true })
465
2501
  process.stdout.write(` ${c.green('✓')} ${c.dim(command)}\n`)
@@ -510,13 +2546,39 @@ async function runConvoy(
510
2546
  }
511
2547
  }
512
2548
 
2549
+ // ── post_convoy hooks ─────────────────────────────────────────────────────
2550
+
2551
+ const specLevelHooks: Hook[] = spec.hooks ?? []
2552
+ if (specLevelHooks.length > 0) {
2553
+ const postConvoyResult = await runHooks(specLevelHooks, 'post_convoy', {
2554
+ convoyId,
2555
+ cwd: basePath,
2556
+ })
2557
+ if (!postConvoyResult.passed) {
2558
+ const hookLabel = postConvoyResult.failedHook?.name ?? postConvoyResult.failedHook?.type ?? 'unknown'
2559
+ events.emit('post_convoy_hook_failed', {
2560
+ hook: hookLabel,
2561
+ error: postConvoyResult.error,
2562
+ }, { convoy_id: convoyId })
2563
+ process.stdout.write(` ${c.red('✗')} post_convoy hook "${hookLabel}" failed\n`)
2564
+ }
2565
+ }
2566
+
2567
+ // ── Intelligence: post-convoy consolidation ──────────────────────────────
2568
+ if (spec.defaults?.inject_lessons !== false) {
2569
+ try { consolidateLessons(basePath) } catch { /* non-critical */ }
2570
+ }
2571
+ if (spec.defaults?.track_discovered_issues) {
2572
+ try { consolidateIssues(basePath) } catch { /* non-critical */ }
2573
+ }
2574
+
513
2575
  // ── Final status & summary ────────────────────────────────────────────────
514
2576
 
515
2577
  const allTasksFinal = store.getTasksByConvoy(convoyId)
516
2578
  const summary = {
517
2579
  total: allTasksFinal.length,
518
2580
  done: allTasksFinal.filter(t => t.status === 'done').length,
519
- failed: allTasksFinal.filter(t => t.status === 'failed').length,
2581
+ failed: allTasksFinal.filter(t => t.status === 'failed' || t.status === 'gate-failed' || t.status === 'review-blocked' || t.status === 'disputed').length,
520
2582
  skipped: allTasksFinal.filter(t => t.status === 'skipped').length,
521
2583
  timedOut: allTasksFinal.filter(t => t.status === 'timed-out').length,
522
2584
  }
@@ -541,6 +2603,19 @@ async function runConvoy(
541
2603
  total_tokens: convoyTotalTokens,
542
2604
  })
543
2605
 
2606
+ // Run convoy guard checks
2607
+ const guardResult = runConvoyGuard(store, convoyId, wtManager, ndjsonPath, spec.guard)
2608
+ if (guardResult.warnings.length > 0) {
2609
+ process.stdout.write(`\n ${c.yellow('Guard warnings:')}\n`)
2610
+ for (const w of guardResult.warnings) {
2611
+ process.stdout.write(` ${c.dim('⚠')} ${w}\n`)
2612
+ }
2613
+ events.emit('convoy_guard', {
2614
+ passed: guardResult.passed,
2615
+ warnings: guardResult.warnings,
2616
+ }, { convoy_id: convoyId })
2617
+ }
2618
+
544
2619
  return {
545
2620
  convoyId,
546
2621
  status: finalStatus,
@@ -575,9 +2650,50 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
575
2650
  const specHash = createHash('sha256').update(specYaml).digest('hex')
576
2651
  const baseBranch = spec.branch ?? (await getCurrentBranch())
577
2652
 
2653
+ // Ensure target branch exists before acquiring any locks.
2654
+ // Uses _ensureBranch injection so callers/tests can override.
2655
+ if (spec.branch !== undefined) {
2656
+ const branchFn = options._ensureBranch ?? ensureBranch
2657
+ await branchFn(spec.branch, basePath)
2658
+ }
2659
+
578
2660
  mkdirSync(dirname(dbPath), { recursive: true })
2661
+
2662
+ const lockDb = new DatabaseSync(dbPath)
2663
+ lockDb.exec('PRAGMA journal_mode = WAL')
2664
+ lockDb.exec(`CREATE TABLE IF NOT EXISTS engine_lock (
2665
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2666
+ pid INTEGER NOT NULL,
2667
+ hostname TEXT NOT NULL,
2668
+ started_at TEXT NOT NULL,
2669
+ last_heartbeat TEXT NOT NULL
2670
+ )`)
2671
+
2672
+ const lock = (() => {
2673
+ try {
2674
+ return acquireEngineLock(lockDb, dbPath)
2675
+ } catch (err) {
2676
+ lockDb.close()
2677
+ throw err
2678
+ }
2679
+ })()
2680
+
2681
+ const versionRow = lockDb.prepare('SELECT sqlite_version() as v').get() as { v: string }
2682
+ const [major, minor] = versionRow.v.split('.').map(Number)
2683
+ if (major < 3 || (major === 3 && minor < 35)) {
2684
+ lock.release()
2685
+ lockDb.close()
2686
+ throw new Error(`SQLite version ${versionRow.v} is too old. Requires >= 3.35.0`)
2687
+ }
2688
+
2689
+ lock.startHeartbeat()
2690
+
579
2691
  const store = createConvoyStore(dbPath)
580
- const events = createEventEmitter(store, options.logsDir)
2692
+ const ndjsonPath = options.logsDir
2693
+ ? join(options.logsDir, 'convoy-events.ndjson')
2694
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson')
2695
+ mkdirSync(dirname(ndjsonPath), { recursive: true })
2696
+ const events = createEventEmitter(store, { ndjsonPath })
581
2697
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
582
2698
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
583
2699
 
@@ -596,6 +2712,24 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
596
2712
 
597
2713
  const tasks = spec.tasks ?? []
598
2714
  const phases = buildPhases(tasks)
2715
+
2716
+ // Validate file partitions before inserting tasks
2717
+ const partitionResult = validateFilePartitions(tasks, phases)
2718
+ if (!partitionResult.valid) {
2719
+ const conflictSummary = partitionResult.conflicts
2720
+ .map(
2721
+ (cf) =>
2722
+ `Phase ${cf.phase}: tasks "${cf.taskA}" and "${cf.taskB}" overlap on [${cf.overlapping.join(', ')}]`,
2723
+ )
2724
+ .join('\n')
2725
+ events.emit(
2726
+ 'file_partition_conflict',
2727
+ { conflicts: partitionResult.conflicts },
2728
+ { convoy_id: convoyId },
2729
+ )
2730
+ throw new Error(`File partition conflicts detected:\n${conflictSummary}`)
2731
+ }
2732
+
599
2733
  for (let phaseIdx = 0; phaseIdx < phases.length; phaseIdx++) {
600
2734
  for (const task of phases[phaseIdx]) {
601
2735
  store.insertTask({
@@ -612,6 +2746,9 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
612
2746
  max_retries: task.max_retries,
613
2747
  files: task.files.length > 0 ? JSON.stringify(task.files) : null,
614
2748
  depends_on: task.depends_on.length > 0 ? JSON.stringify(task.depends_on) : null,
2749
+ gates: task.gates && task.gates.length > 0 ? JSON.stringify(task.gates) : null,
2750
+ outputs: task.outputs && task.outputs.length > 0 ? JSON.stringify(task.outputs) : null,
2751
+ inputs: task.inputs && task.inputs.length > 0 ? JSON.stringify(task.inputs) : null,
615
2752
  })
616
2753
  }
617
2754
  }
@@ -621,11 +2758,15 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
621
2758
 
622
2759
  result = await runConvoy(
623
2760
  convoyId, spec, adapter, store, events,
624
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime,
2761
+ wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
2762
+ options._reviewRunner,
625
2763
  )
626
2764
  } finally {
627
2765
  try { await exportConvoyToNdjson(store, convoyId, options.logsDir) } catch { /* silent */ }
2766
+ events.close()
628
2767
  store.close()
2768
+ lock.release()
2769
+ lockDb.close()
629
2770
  }
630
2771
  return result
631
2772
  }
@@ -634,8 +2775,42 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
634
2775
  const startTime = Date.now()
635
2776
 
636
2777
  mkdirSync(dirname(dbPath), { recursive: true })
2778
+
2779
+ const lockDb = new DatabaseSync(dbPath)
2780
+ lockDb.exec('PRAGMA journal_mode = WAL')
2781
+ lockDb.exec(`CREATE TABLE IF NOT EXISTS engine_lock (
2782
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2783
+ pid INTEGER NOT NULL,
2784
+ hostname TEXT NOT NULL,
2785
+ started_at TEXT NOT NULL,
2786
+ last_heartbeat TEXT NOT NULL
2787
+ )`)
2788
+
2789
+ const lock = (() => {
2790
+ try {
2791
+ return acquireEngineLock(lockDb, dbPath)
2792
+ } catch (err) {
2793
+ lockDb.close()
2794
+ throw err
2795
+ }
2796
+ })()
2797
+
2798
+ const versionRow = lockDb.prepare('SELECT sqlite_version() as v').get() as { v: string }
2799
+ const [major, minor] = versionRow.v.split('.').map(Number)
2800
+ if (major < 3 || (major === 3 && minor < 35)) {
2801
+ lock.release()
2802
+ lockDb.close()
2803
+ throw new Error(`SQLite version ${versionRow.v} is too old. Requires >= 3.35.0`)
2804
+ }
2805
+
2806
+ lock.startHeartbeat()
2807
+
637
2808
  const store = createConvoyStore(dbPath)
638
- const events = createEventEmitter(store, options.logsDir)
2809
+ const ndjsonPath = options.logsDir
2810
+ ? join(options.logsDir, 'convoy-events.ndjson')
2811
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson')
2812
+ mkdirSync(dirname(ndjsonPath), { recursive: true })
2813
+ const events = createEventEmitter(store, { ndjsonPath })
639
2814
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
640
2815
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
641
2816
 
@@ -673,6 +2848,9 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
673
2848
  // Remove all orphaned worktrees from the crashed run
674
2849
  await wtManager.removeAll()
675
2850
 
2851
+ // NDJSON recovery: truncate partial lines, replay missing events
2852
+ recoverNdjson(store, convoyId, ndjsonPath)
2853
+
676
2854
  events.emit(
677
2855
  'convoy_resumed',
678
2856
  { original_created_at: convoy.created_at },
@@ -681,14 +2859,215 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
681
2859
 
682
2860
  result = await runConvoy(
683
2861
  convoyId, spec, adapter, store, events,
684
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime,
2862
+ wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
2863
+ options._reviewRunner,
685
2864
  )
686
2865
  } finally {
687
2866
  try { await exportConvoyToNdjson(store, convoyId, options.logsDir) } catch { /* silent */ }
2867
+ events.close()
688
2868
  store.close()
2869
+ lock.release()
2870
+ lockDb.close()
689
2871
  }
690
2872
  return result
691
2873
  }
692
2874
 
693
- return { run, resume }
2875
+ async function retryFailed(convoyId: string, taskIds?: string[]): Promise<void> {
2876
+ mkdirSync(dirname(dbPath), { recursive: true })
2877
+ const store = createConvoyStore(dbPath)
2878
+ const ndjsonPath = options.logsDir
2879
+ ? join(options.logsDir, 'convoy-events.ndjson')
2880
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson')
2881
+ mkdirSync(dirname(ndjsonPath), { recursive: true })
2882
+ const events = createEventEmitter(store, { ndjsonPath })
2883
+ try {
2884
+ const allTasks = store.getTasksByConvoy(convoyId)
2885
+ const retryableStatuses = ['failed', 'gate-failed', 'timed-out', 'review-blocked', 'disputed']
2886
+
2887
+ const tasksToRetry = allTasks.filter(t => {
2888
+ if (!retryableStatuses.includes(t.status)) return false
2889
+ if (taskIds && taskIds.length > 0) return taskIds.includes(t.id)
2890
+ return true
2891
+ })
2892
+
2893
+ for (const task of tasksToRetry) {
2894
+ store.updateTaskStatus(task.id, convoyId, 'pending', {
2895
+ worker_id: null,
2896
+ worktree: null,
2897
+ started_at: null,
2898
+ finished_at: null,
2899
+ })
2900
+ events.emit('task_retried', { previous_status: task.status }, { convoy_id: convoyId, task_id: task.id })
2901
+ }
2902
+
2903
+ // Reset convoy status to running so resume can pick it up
2904
+ store.updateConvoyStatus(convoyId, 'running', {})
2905
+ } finally {
2906
+ events.close()
2907
+ store.close()
2908
+ }
2909
+ }
2910
+
2911
+ function injectTask(convoyId: string, task: {
2912
+ id: string
2913
+ prompt: string
2914
+ agent: string
2915
+ phase: number
2916
+ timeout_ms?: number
2917
+ depends_on?: string[]
2918
+ files?: string[]
2919
+ max_retries?: number
2920
+ provenance?: string
2921
+ idempotency_key?: string
2922
+ on_exhausted?: 'dlq' | 'skip' | 'stop'
2923
+ }): TaskRecord {
2924
+ mkdirSync(dirname(dbPath), { recursive: true })
2925
+ const store = createConvoyStore(dbPath)
2926
+ try {
2927
+ // Idempotency check
2928
+ if (task.idempotency_key) {
2929
+ const existing = store.getTaskByIdempotencyKey(convoyId, task.idempotency_key)
2930
+ if (existing) return existing
2931
+ }
2932
+
2933
+ const allTasks = store.getTasksByConvoy(convoyId)
2934
+
2935
+ // Check max injectable tasks (10)
2936
+ const injectedCount = allTasks.filter(t => t.injected === 1).length
2937
+ if (injectedCount >= 10) {
2938
+ throw new Error(`Max injectable tasks (10) reached for convoy ${convoyId}`)
2939
+ }
2940
+
2941
+ // Validate ID uniqueness
2942
+ if (allTasks.some(t => t.id === task.id)) {
2943
+ throw new Error(`Task ID "${task.id}" already exists in convoy ${convoyId}`)
2944
+ }
2945
+
2946
+ // Validate depends_on references exist
2947
+ const deps = task.depends_on ?? []
2948
+ for (const dep of deps) {
2949
+ if (!allTasks.some(t => t.id === dep)) {
2950
+ throw new Error(`Dependency "${dep}" not found in convoy ${convoyId}`)
2951
+ }
2952
+ }
2953
+
2954
+ // Validate no file partition overlap with pending/running tasks
2955
+ const taskFiles = task.files ?? []
2956
+ if (taskFiles.length > 0) {
2957
+ // Normalize injected task file paths
2958
+ const normalizedTaskFiles = taskFiles.map(normalizePath)
2959
+
2960
+ // Symlink pre-scan on injected files
2961
+ const basePath = options.basePath ?? process.cwd()
2962
+ try {
2963
+ scanSymlinks(normalizedTaskFiles, basePath)
2964
+ } catch (err) {
2965
+ throw new Error(`Injected task "${task.id}" failed symlink check: ${(err as Error).message}`)
2966
+ }
2967
+
2968
+ // Full partition validation against active tasks
2969
+ const activeTasks = allTasks.filter(t => t.status === 'pending' || t.status === 'running' || t.status === 'assigned')
2970
+ for (const other of activeTasks) {
2971
+ const otherFiles = other.files ? (JSON.parse(other.files) as string[]) : []
2972
+ if (otherFiles.length === 0) continue
2973
+ const normalizedOther = otherFiles.map(normalizePath)
2974
+ const overlapping: string[] = []
2975
+ for (const fileA of normalizedTaskFiles) {
2976
+ for (const fileB of normalizedOther) {
2977
+ if (pathsOverlap(fileA, fileB) && !overlapping.includes(fileA)) {
2978
+ overlapping.push(fileA)
2979
+ }
2980
+ }
2981
+ }
2982
+ if (overlapping.length > 0) {
2983
+ throw new Error(`File partition overlap with task "${other.id}": ${overlapping.join(', ')}`)
2984
+ }
2985
+ }
2986
+ }
2987
+
2988
+ // Detect dependency cycles
2989
+ const depGraph = new Map<string, string[]>()
2990
+ for (const t of allTasks) {
2991
+ depGraph.set(t.id, t.depends_on ? (JSON.parse(t.depends_on) as string[]) : [])
2992
+ }
2993
+ depGraph.set(task.id, deps)
2994
+
2995
+ function hasCycle(nodeId: string, visited: Set<string>, stack: Set<string>): boolean {
2996
+ visited.add(nodeId)
2997
+ stack.add(nodeId)
2998
+ for (const dep of depGraph.get(nodeId) ?? []) {
2999
+ if (!visited.has(dep)) {
3000
+ if (hasCycle(dep, visited, stack)) return true
3001
+ } else if (stack.has(dep)) {
3002
+ return true
3003
+ }
3004
+ }
3005
+ stack.delete(nodeId)
3006
+ return false
3007
+ }
3008
+
3009
+ const visited = new Set<string>()
3010
+ const stack = new Set<string>()
3011
+ for (const nodeId of depGraph.keys()) {
3012
+ if (!visited.has(nodeId)) {
3013
+ if (hasCycle(nodeId, visited, stack)) {
3014
+ throw new Error(`Dependency cycle detected when injecting task "${task.id}"`)
3015
+ }
3016
+ }
3017
+ }
3018
+
3019
+ // Insert the task
3020
+ const record: TaskRecord = {
3021
+ id: task.id,
3022
+ convoy_id: convoyId,
3023
+ phase: task.phase,
3024
+ prompt: task.prompt,
3025
+ agent: task.agent,
3026
+ adapter: null,
3027
+ model: null,
3028
+ timeout_ms: task.timeout_ms ?? 1_800_000,
3029
+ status: 'pending',
3030
+ worker_id: null,
3031
+ worktree: null,
3032
+ output: null,
3033
+ exit_code: null,
3034
+ started_at: null,
3035
+ finished_at: null,
3036
+ retries: 0,
3037
+ max_retries: task.max_retries ?? 1,
3038
+ files: taskFiles.length > 0 ? JSON.stringify(taskFiles) : null,
3039
+ depends_on: deps.length > 0 ? JSON.stringify(deps) : null,
3040
+ prompt_tokens: null,
3041
+ completion_tokens: null,
3042
+ total_tokens: null,
3043
+ cost_usd: null,
3044
+ gates: null,
3045
+ on_exhausted: task.on_exhausted ?? 'dlq',
3046
+ injected: 1,
3047
+ provenance: task.provenance ?? null,
3048
+ idempotency_key: task.idempotency_key ?? null,
3049
+ current_step: null,
3050
+ total_steps: null,
3051
+ review_level: null,
3052
+ review_verdict: null,
3053
+ review_tokens: null,
3054
+ review_model: null,
3055
+ panel_attempts: 0,
3056
+ dispute_id: null,
3057
+ drift_score: null,
3058
+ drift_retried: 0,
3059
+ outputs: null,
3060
+ inputs: null,
3061
+ discovered_issues: null,
3062
+ }
3063
+
3064
+ store.insertInjectedTask(record)
3065
+
3066
+ return record
3067
+ } finally {
3068
+ store.close()
3069
+ }
3070
+ }
3071
+
3072
+ return { run, resume, retryFailed, injectTask }
694
3073
  }