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,320 @@
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 { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { dirname, join, resolve } from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
5
6
  import { promisify } from 'node:util';
6
- import { createConvoyStore } from './store.js';
7
+ import { createConvoyStore, ConvoyArtifactLimitError } from './store.js';
8
+ import { acquireEngineLock } from './lock.js';
7
9
  import { createEventEmitter } from './events.js';
8
10
  import { createWorktreeManager } from './worktree.js';
9
- import { createMergeQueue } from './merge.js';
10
- import { createHealthMonitor } from './health.js';
11
+ import { createMergeQueue, MergeConflictError } from './merge.js';
12
+ import { createHealthMonitor, detectDrift } from './health.js';
11
13
  import { exportConvoyToNdjson } from './export.js';
12
14
  import { buildPhases, formatDuration } from '../run/executor.js';
13
- import { parseTimeout } from '../run/schema.js';
15
+ import { parseTimeout, parseYaml } from '../run/schema.js';
14
16
  import { getAdapter, detectAdapter } from '../run/adapters/index.js';
15
17
  import { c } from '../prompt.js';
18
+ import { validateFilePartitions, scanSymlinks, scanNewSymlinks, normalizePath, pathsOverlap } from './partition.js';
19
+ import { scanForSecrets, runSecretScanGate, runBlastRadiusGate, browserTestGate } from './gates.js';
20
+ import { readLessons, captureLessons, consolidateLessons } from './lessons.js';
21
+ import { updateExpertise, feedCircuitBreaker } from './expertise.js';
22
+ import { buildKnowledgeGraph } from './knowledge.js';
23
+ import { injectDiscoveredIssuesInstruction, checkDiscoveredIssues, consolidateIssues } from './issues.js';
16
24
  const execFile = promisify(execFileCb);
25
+ export class CircuitBreakerManager {
26
+ states = new Map();
27
+ threshold;
28
+ cooldownMs;
29
+ fallbackAgent;
30
+ constructor(config, initialState) {
31
+ this.threshold = config?.threshold ?? 3;
32
+ this.cooldownMs = config?.cooldown_ms ?? 300_000;
33
+ this.fallbackAgent = config?.fallback_agent ?? null;
34
+ if (initialState) {
35
+ for (const [agent, state] of Object.entries(initialState)) {
36
+ this.states.set(agent, state);
37
+ }
38
+ }
39
+ }
40
+ getState(agent) {
41
+ return this.states.get(agent) ?? { status: 'closed', failures: 0, last_failure_at: null, opened_at: null };
42
+ }
43
+ recordFailure(agent) {
44
+ const state = this.getState(agent);
45
+ const now = new Date().toISOString();
46
+ if (state.status === 'half-open') {
47
+ // Probe failed — back to open, reset cooldown
48
+ state.status = 'open';
49
+ state.opened_at = now;
50
+ state.last_failure_at = now;
51
+ this.states.set(agent, state);
52
+ return { tripped: true, state };
53
+ }
54
+ state.failures += 1;
55
+ state.last_failure_at = now;
56
+ if (state.failures >= this.threshold) {
57
+ state.status = 'open';
58
+ state.opened_at = now;
59
+ this.states.set(agent, state);
60
+ return { tripped: true, state };
61
+ }
62
+ this.states.set(agent, state);
63
+ return { tripped: false, state };
64
+ }
65
+ recordSuccess(agent) {
66
+ const state = this.getState(agent);
67
+ if (state.status === 'half-open') {
68
+ // Probe succeeded — close circuit
69
+ state.status = 'closed';
70
+ state.failures = 0;
71
+ state.opened_at = null;
72
+ }
73
+ else if (state.status === 'closed') {
74
+ state.failures = 0;
75
+ }
76
+ this.states.set(agent, state);
77
+ return state;
78
+ }
79
+ canAssign(agent) {
80
+ const state = this.getState(agent);
81
+ if (state.status === 'closed')
82
+ return true;
83
+ if (state.status === 'half-open')
84
+ return true; // allow 1 probe
85
+ // Open — check cooldown
86
+ if (state.opened_at) {
87
+ const elapsed = Date.now() - new Date(state.opened_at).getTime();
88
+ if (elapsed >= this.cooldownMs) {
89
+ state.status = 'half-open';
90
+ this.states.set(agent, state);
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+ get fallback() {
97
+ return this.fallbackAgent;
98
+ }
99
+ serialize() {
100
+ return JSON.stringify(Object.fromEntries(this.states));
101
+ }
102
+ }
103
+ // ── Branch management ───────────────────────────────────────────────────────
104
+ /**
105
+ * Ensure the given branch exists and is checked out.
106
+ * Creates the branch from HEAD if it does not yet exist.
107
+ * Fails fast if there are uncommitted changes.
108
+ */
109
+ export async function ensureBranch(branchName, basePath) {
110
+ // Validate refspec — reject shell metacharacters
111
+ if (!/^[a-zA-Z0-9\-/_\.]+$/.test(branchName)) {
112
+ throw new Error(`Invalid branch name "${branchName}": only alphanumeric, -, /, _, and . are allowed`);
113
+ }
114
+ // Refuse to switch branches with uncommitted changes
115
+ const { stdout: statusOut } = await execFile('git', ['status', '--porcelain'], {
116
+ cwd: basePath,
117
+ });
118
+ if (statusOut.trim()) {
119
+ throw new Error(`Uncommitted changes detected in "${basePath}". Commit or stash before switching branches.`);
120
+ }
121
+ // Check if branch already exists
122
+ try {
123
+ await execFile('git', ['rev-parse', '--verify', branchName], { cwd: basePath });
124
+ // Branch exists — check it out
125
+ await execFile('git', ['checkout', branchName], { cwd: basePath });
126
+ }
127
+ catch {
128
+ // Branch does not exist — create from current HEAD
129
+ await execFile('git', ['checkout', '-b', branchName], { cwd: basePath });
130
+ }
131
+ }
17
132
  // ── Internal helpers ──────────────────────────────────────────────────────────
133
+ /**
134
+ * Truncate any trailing partial line in the NDJSON file, then replay any SQLite
135
+ * events for the given convoy that are missing from the file.
136
+ * Exported for unit testing.
137
+ */
138
+ function safeJsonParse(raw) {
139
+ try {
140
+ return JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return {};
144
+ }
145
+ }
146
+ export function recoverNdjson(store, convoyId, ndjsonPath) {
147
+ // 1. Read the NDJSON file (if it exists)
148
+ let fileContent;
149
+ try {
150
+ fileContent = readFileSync(ndjsonPath, 'utf8');
151
+ }
152
+ catch {
153
+ fileContent = '';
154
+ }
155
+ // 2. Truncate any partial trailing line (no \n terminator)
156
+ if (fileContent.length > 0 && !fileContent.endsWith('\n')) {
157
+ const lastNewline = fileContent.lastIndexOf('\n');
158
+ if (lastNewline === -1) {
159
+ writeFileSync(ndjsonPath, '');
160
+ fileContent = '';
161
+ }
162
+ else {
163
+ writeFileSync(ndjsonPath, fileContent.slice(0, lastNewline + 1));
164
+ fileContent = fileContent.slice(0, lastNewline + 1);
165
+ }
166
+ }
167
+ // 3. Count valid NDJSON event IDs for this convoy
168
+ const ndjsonIds = new Set();
169
+ for (const line of fileContent.split('\n')) {
170
+ if (!line.trim())
171
+ continue;
172
+ try {
173
+ const parsed = JSON.parse(line);
174
+ if (parsed.convoy_id === convoyId && parsed._event_id != null) {
175
+ ndjsonIds.add(parsed._event_id);
176
+ }
177
+ }
178
+ catch {
179
+ // Skip unparseable lines
180
+ }
181
+ }
182
+ // 4. Get all SQLite events for this convoy
183
+ const sqliteEvents = store.getEvents(convoyId);
184
+ // 5. Replay missing events (those in SQLite but not in NDJSON)
185
+ const missing = sqliteEvents.filter(e => e.id != null && !ndjsonIds.has(e.id));
186
+ if (missing.length > 0) {
187
+ const fd = openSync(ndjsonPath, 'a');
188
+ try {
189
+ for (const event of missing) {
190
+ const parsedData = event.data ? safeJsonParse(event.data) : {};
191
+ const record = {
192
+ ...parsedData,
193
+ _event_id: event.id,
194
+ timestamp: event.created_at,
195
+ type: event.type,
196
+ convoy_id: event.convoy_id,
197
+ task_id: event.task_id,
198
+ worker_id: event.worker_id,
199
+ };
200
+ appendFileSync(fd, JSON.stringify(record) + '\n');
201
+ }
202
+ fsyncSync(fd);
203
+ }
204
+ finally {
205
+ closeSync(fd);
206
+ }
207
+ }
208
+ }
209
+ export function runConvoyGuard(store, convoyId, _wtManager, ndjsonPath, guardConfig) {
210
+ // If guard is explicitly disabled, skip all checks
211
+ if (guardConfig?.enabled === false) {
212
+ return { passed: true, warnings: [] };
213
+ }
214
+ const warnings = [];
215
+ const tasks = store.getTasksByConvoy(convoyId);
216
+ // Check 1: All task statuses are terminal
217
+ const terminalStatuses = new Set(['done', 'failed', 'skipped', 'timed-out', 'gate-failed', 'review-blocked', 'hook-failed', 'disputed']);
218
+ const nonTerminal = tasks.filter(t => !terminalStatuses.has(t.status));
219
+ if (nonTerminal.length > 0) {
220
+ warnings.push(`Non-terminal tasks: ${nonTerminal.map(t => `${t.id}(${t.status})`).join(', ')}`);
221
+ }
222
+ // Check 2: NDJSON file exists and record count >= completed task count
223
+ const completedTasks = tasks.filter(t => t.status === 'done');
224
+ try {
225
+ const content = readFileSync(ndjsonPath, 'utf8');
226
+ const lines = content.split('\n').filter(l => l.trim());
227
+ const convoyLines = lines.filter(l => {
228
+ try {
229
+ return JSON.parse(l).convoy_id === convoyId;
230
+ }
231
+ catch {
232
+ return false;
233
+ }
234
+ });
235
+ if (convoyLines.length < completedTasks.length) {
236
+ warnings.push(`NDJSON record count (${convoyLines.length}) < completed tasks (${completedTasks.length})`);
237
+ }
238
+ }
239
+ catch {
240
+ if (completedTasks.length > 0) {
241
+ warnings.push(`NDJSON file not found at ${ndjsonPath} but ${completedTasks.length} tasks completed`);
242
+ }
243
+ }
244
+ // Check 3: Every retried task has events for each attempt
245
+ const retriedTasks = tasks.filter(t => t.retries > 0);
246
+ const events = store.getEvents(convoyId);
247
+ for (const task of retriedTasks) {
248
+ const taskEvents = events.filter(e => e.task_id === task.id && e.type === 'task_started');
249
+ if (taskEvents.length < task.retries) {
250
+ warnings.push(`Task ${task.id} has ${task.retries} retries but only ${taskEvents.length} task_started events`);
251
+ }
252
+ }
253
+ // Check 4: Gate results recorded for all gates that ran
254
+ const gateEvents = events.filter(e => e.type === 'built_in_gate_result' || (e.data != null && e.data.includes('gate')));
255
+ const tasksWithGates = tasks.filter(t => t.gates);
256
+ if (tasksWithGates.length > 0 && gateEvents.length === 0) {
257
+ warnings.push('Tasks have gates configured but no gate result events found');
258
+ }
259
+ // Check 5: Token/cost totals computed
260
+ const convoy = store.getConvoy(convoyId);
261
+ if (convoy && convoy.total_tokens == null) {
262
+ const totalTokens = tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0);
263
+ if (totalTokens > 0) {
264
+ warnings.push('Convoy total_tokens not persisted despite tasks having token data');
265
+ }
266
+ }
267
+ // Check 6: No orphaned worktrees — engine already calls removeAll() during cleanup.
268
+ // Synchronous check is not possible; the engine handles this.
269
+ return { passed: warnings.length === 0, warnings };
270
+ }
271
+ export function evaluateReviewLevel(task, diff, heuristics, allGatesPassed) {
272
+ const panelPaths = heuristics?.panel_paths ?? ['auth/', 'security/', 'migrations/', 'rls/'];
273
+ const panelAgents = heuristics?.panel_agents ?? ['security-expert', 'database-engineer'];
274
+ const autoPassAgents = heuristics?.auto_pass_agents ?? ['documentation-writer', 'copywriter'];
275
+ const autoPassMaxLines = heuristics?.auto_pass_max_lines ?? 10;
276
+ const autoPassMaxFiles = heuristics?.auto_pass_max_files ?? 2;
277
+ // Panel: sensitive paths or agents
278
+ if (panelPaths.some(p => diff.filePaths.some(fp => fp.startsWith(p) || fp.includes('/' + p))))
279
+ return 'panel';
280
+ if (panelAgents.includes(task.agent))
281
+ return 'panel';
282
+ // Auto-pass: documentation/copy agents
283
+ if (autoPassAgents.includes(task.agent))
284
+ return 'auto-pass';
285
+ // Auto-pass: small diffs with all gates passing
286
+ if (diff.linesChanged <= autoPassMaxLines && diff.filesChanged <= autoPassMaxFiles && allGatesPassed !== false)
287
+ return 'auto-pass';
288
+ // Large diffs → fast review
289
+ if (diff.linesChanged > 200 || diff.filesChanged > 5)
290
+ return 'fast';
291
+ // Default → fast review
292
+ return 'fast';
293
+ }
294
+ class ReviewSemaphore {
295
+ max;
296
+ current = 0;
297
+ queue = [];
298
+ constructor(max) {
299
+ this.max = max;
300
+ }
301
+ async acquire() {
302
+ if (this.current < this.max) {
303
+ this.current++;
304
+ return;
305
+ }
306
+ return new Promise(resolve => {
307
+ this.queue.push(() => { this.current++; resolve(); });
308
+ });
309
+ }
310
+ release() {
311
+ this.current--;
312
+ if (this.queue.length > 0) {
313
+ const next = this.queue.shift();
314
+ next();
315
+ }
316
+ }
317
+ }
18
318
  function msToTimeout(ms) {
19
319
  if (ms >= 3_600_000 && ms % 3_600_000 === 0)
20
320
  return `${ms / 3_600_000}h`;
@@ -22,6 +322,58 @@ function msToTimeout(ms) {
22
322
  return `${ms / 60_000}m`;
23
323
  return `${ms / 1_000}s`;
24
324
  }
325
+ // ── DLQ markdown dual-write ───────────────────────────────────────────────────
326
+ // Builds the DLQ markdown entry text (no I/O, no scanning).
327
+ function buildDlqMarkdownEntry(dlqId, task, failureType, errorOutput) {
328
+ const marker = `<!-- dlq:${dlqId} -->`;
329
+ 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`;
330
+ return { marker, entry };
331
+ }
332
+ // Appends a pre-scanned DLQ entry to AGENT-FAILURES.md. The caller must have
333
+ // already verified the entry is clean via scanForSecrets — no re-scan here.
334
+ function appendDlqMarkdownClean(marker, entry) {
335
+ const mdPath = join(resolve(process.cwd()), '.opencastle', 'AGENT-FAILURES.md');
336
+ try {
337
+ const existing = readFileSync(mdPath, 'utf8');
338
+ if (existing.includes(marker))
339
+ return;
340
+ }
341
+ catch {
342
+ // File doesn't exist yet — will create
343
+ }
344
+ mkdirSync(dirname(mdPath), { recursive: true });
345
+ appendFileSync(mdPath, entry);
346
+ }
347
+ function writeDisputeToMarkdown(disputeId, convoyId, task, panelResults, events) {
348
+ const mdPath = join(resolve(process.cwd()), 'DISPUTES.md');
349
+ const marker = `<!-- dispute:${disputeId} -->`;
350
+ try {
351
+ const existing = readFileSync(mdPath, 'utf8');
352
+ if (existing.includes(marker))
353
+ return;
354
+ }
355
+ catch {
356
+ // File doesn't exist yet
357
+ }
358
+ const blockingReasons = panelResults
359
+ .filter(r => r.verdict === 'block')
360
+ .map(r => r.feedback)
361
+ .join('\n\n');
362
+ 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`;
363
+ const scanResult = scanForSecrets(entry, 'DISPUTES.md');
364
+ if (!scanResult.clean) {
365
+ if (events) {
366
+ events.emit('secret_leak_prevented', {
367
+ task_id: task.id,
368
+ findings_count: scanResult.findings.length,
369
+ patterns: scanResult.findings.map((f) => f.pattern),
370
+ context: 'dispute_markdown_write',
371
+ }, { convoy_id: convoyId, task_id: task.id });
372
+ }
373
+ return;
374
+ }
375
+ appendFileSync(mdPath, entry);
376
+ }
25
377
  function taskRecordToTask(record) {
26
378
  return {
27
379
  id: record.id,
@@ -34,6 +386,7 @@ function taskRecordToTask(record) {
34
386
  model: record.model ?? undefined,
35
387
  max_retries: record.max_retries,
36
388
  adapter: record.adapter ?? undefined,
389
+ gates: record.gates ? JSON.parse(record.gates) : undefined,
37
390
  };
38
391
  }
39
392
  function makeTimeoutPromise(ms) {
@@ -44,11 +397,322 @@ function makeTimeoutPromise(ms) {
44
397
  return { promise, clear: () => { if (timerId !== undefined)
45
398
  clearTimeout(timerId); } };
46
399
  }
400
+ // ── Step condition evaluation ─────────────────────────────────────────────────
401
+ function evaluateStepCondition(condition, stepResults, worktreePath, basePath) {
402
+ if (!condition)
403
+ return true;
404
+ if (condition.exitCode) {
405
+ const prevResult = stepResults.get(condition.step);
406
+ if (!prevResult)
407
+ return false;
408
+ const code = prevResult.exitCode;
409
+ const ec = condition.exitCode;
410
+ if (ec.eq !== undefined && code !== ec.eq)
411
+ return false;
412
+ if (ec.ne !== undefined && code === ec.ne)
413
+ return false;
414
+ if (ec.gt !== undefined && !(code > ec.gt))
415
+ return false;
416
+ if (ec.lt !== undefined && !(code < ec.lt))
417
+ return false;
418
+ }
419
+ if (condition.fileExists) {
420
+ const base = worktreePath ?? basePath;
421
+ if (condition.fileExists.path.startsWith('/')) {
422
+ return false; // Absolute paths not allowed in step conditions
423
+ }
424
+ const filePath = join(base, condition.fileExists.path);
425
+ const resolved = resolve(filePath);
426
+ const resolvedBase = resolve(base);
427
+ if (!resolved.startsWith(resolvedBase + '/') && resolved !== resolvedBase) {
428
+ return false; // path escapes the worktree — treat as "file doesn't exist"
429
+ }
430
+ if (!existsSync(filePath))
431
+ return false;
432
+ }
433
+ return true;
434
+ }
435
+ async function executeSteps(taskRecord, steps, adapter, worktreePath, basePath, store, convoyId, verbose) {
436
+ const now = () => new Date().toISOString();
437
+ const stepResults = new Map();
438
+ let combinedOutput = '';
439
+ let lastExitCode = 0;
440
+ // Track total_steps in DB
441
+ store.updateTaskStatus(taskRecord.id, convoyId, 'running', {});
442
+ for (let i = 0; i < steps.length; i++) {
443
+ const step = steps[i];
444
+ // Evaluate condition — skip step if condition is not met
445
+ if (step.if) {
446
+ const condMet = evaluateStepCondition(step.if, stepResults, worktreePath, basePath);
447
+ if (!condMet) {
448
+ const stepId = store.insertTaskStep({
449
+ task_id: taskRecord.id,
450
+ step_index: i,
451
+ prompt: step.prompt,
452
+ gates: step.gates ? JSON.stringify(step.gates) : null,
453
+ status: 'skipped',
454
+ exit_code: null,
455
+ output: 'Skipped: condition not met',
456
+ started_at: now(),
457
+ finished_at: now(),
458
+ });
459
+ if (step.id) {
460
+ stepResults.set(step.id, { exitCode: 0 });
461
+ }
462
+ combinedOutput += `\n[Step ${i + 1} skipped: condition not met]`;
463
+ continue;
464
+ }
465
+ }
466
+ // Insert step record as running
467
+ const stepDbId = store.insertTaskStep({
468
+ task_id: taskRecord.id,
469
+ step_index: i,
470
+ prompt: step.prompt,
471
+ gates: step.gates ? JSON.stringify(step.gates) : null,
472
+ status: 'running',
473
+ exit_code: null,
474
+ output: null,
475
+ started_at: now(),
476
+ finished_at: null,
477
+ });
478
+ // Update current_step on the task record
479
+ store.updateTaskStatus(taskRecord.id, convoyId, 'running', {});
480
+ const stepMaxRetries = step.max_retries ?? taskRecord.max_retries;
481
+ let stepResult = { success: false, output: '', exitCode: -1 };
482
+ let stepAttempt = 0;
483
+ while (stepAttempt <= stepMaxRetries) {
484
+ // Prepend prior failure context on retries
485
+ let stepPrompt = step.prompt;
486
+ if (stepAttempt > 0 && stepResult) {
487
+ const failedOutput = stepResult.output || '(no output)';
488
+ stepPrompt = `Previous attempt failed.\nExit code: ${stepResult.exitCode}\nError output:\n${failedOutput}\n\nFix the issues and try again.\n\n` + step.prompt;
489
+ }
490
+ const stepTask = {
491
+ id: taskRecord.id,
492
+ prompt: stepPrompt,
493
+ agent: taskRecord.agent,
494
+ timeout: `${taskRecord.timeout_ms}ms`,
495
+ depends_on: [],
496
+ files: taskRecord.files ? JSON.parse(taskRecord.files) : [],
497
+ description: `step ${i + 1}`,
498
+ max_retries: stepMaxRetries,
499
+ };
500
+ try {
501
+ stepResult = await adapter.execute(stepTask, { verbose, cwd: worktreePath ?? basePath });
502
+ }
503
+ catch (err) {
504
+ stepResult = { success: false, output: err.message, exitCode: -1 };
505
+ }
506
+ if (stepResult.success)
507
+ break;
508
+ stepAttempt++;
509
+ if (stepAttempt <= stepMaxRetries) {
510
+ process.stdout.write(` ↺ step ${i + 1}/${steps.length} failed, retry ${stepAttempt}/${stepMaxRetries}\n`);
511
+ }
512
+ }
513
+ lastExitCode = stepResult.exitCode;
514
+ combinedOutput += `\n[Step ${i + 1}]\n${stepResult.output}`;
515
+ if (step.id) {
516
+ stepResults.set(step.id, { exitCode: stepResult.exitCode });
517
+ }
518
+ // Run step-level gates if present
519
+ if (step.gates && step.gates.length > 0 && stepResult.success) {
520
+ let gateFailure = null;
521
+ const execFileCb = (await import('node:child_process')).execFile;
522
+ const execFileP = (await import('node:util')).promisify(execFileCb);
523
+ for (const command of step.gates) {
524
+ try {
525
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
526
+ // They are NOT user-supplied and are part of the trusted build configuration.
527
+ await execFileP('sh', ['-c', command], { cwd: worktreePath ?? basePath });
528
+ }
529
+ catch (gateErr) {
530
+ const ge = gateErr;
531
+ const code = typeof ge.code === 'number' ? ge.code : 1;
532
+ const output = ge.stderr || ge.stdout || ge.message || '';
533
+ gateFailure = { command, exitCode: code, output };
534
+ break;
535
+ }
536
+ }
537
+ if (gateFailure !== null) {
538
+ stepResult = { success: false, output: `Gate failed: ${gateFailure.command}\nExit code: ${gateFailure.exitCode}\n${gateFailure.output}`, exitCode: gateFailure.exitCode };
539
+ lastExitCode = gateFailure.exitCode;
540
+ combinedOutput += `\n[Step ${i + 1} gate failed: ${gateFailure.command}]`;
541
+ }
542
+ }
543
+ // Update step record
544
+ store.updateTaskStep(stepDbId, {
545
+ status: stepResult.success ? 'done' : 'failed',
546
+ exit_code: stepResult.exitCode,
547
+ output: stepResult.output,
548
+ finished_at: now(),
549
+ });
550
+ if (!stepResult.success) {
551
+ return {
552
+ success: false,
553
+ output: combinedOutput.trim(),
554
+ exitCode: lastExitCode,
555
+ };
556
+ }
557
+ }
558
+ return {
559
+ success: true,
560
+ output: combinedOutput.trim(),
561
+ exitCode: lastExitCode,
562
+ };
563
+ }
564
+ // ── File-based injection ──────────────────────────────────────────────────────
565
+ const INJECT_DIR = '.opencastle/convoy-inject';
566
+ const CONVOY_ID_RE = /^[a-zA-Z0-9-]+$/;
567
+ const MAX_FILE_INJECTED_TASKS = 10;
568
+ function pollInjectFile(convoyId, store, events, basePath) {
569
+ // Path traversal guard: convoy_id must be alphanumeric + hyphens only
570
+ if (!CONVOY_ID_RE.test(convoyId))
571
+ return 0;
572
+ const injectDir = join(basePath, INJECT_DIR, convoyId);
573
+ const injectPath = join(injectDir, 'inject.yml');
574
+ if (!existsSync(injectPath))
575
+ return 0;
576
+ // Atomic rename to prevent double-read
577
+ const processingPath = injectPath + '.processing';
578
+ try {
579
+ renameSync(injectPath, processingPath);
580
+ }
581
+ catch {
582
+ return 0; // Another process may have grabbed it
583
+ }
584
+ let raw;
585
+ try {
586
+ raw = readFileSync(processingPath, 'utf8');
587
+ }
588
+ catch {
589
+ return 0;
590
+ }
591
+ finally {
592
+ try {
593
+ unlinkSync(processingPath);
594
+ }
595
+ catch { /* ignore */ }
596
+ }
597
+ let parsed;
598
+ try {
599
+ parsed = parseYaml(raw);
600
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.tasks)) {
601
+ process.stderr.write(`Warning: inject file has invalid format (expected { tasks: [...] })\n`);
602
+ return 0;
603
+ }
604
+ }
605
+ catch (err) {
606
+ process.stderr.write(`Warning: failed to parse inject file: ${err.message}\n`);
607
+ return 0;
608
+ }
609
+ const tasks = parsed.tasks;
610
+ const allExisting = store.getTasksByConvoy(convoyId);
611
+ const existingFileInjected = allExisting.filter(t => t.provenance === 'file-injection').length;
612
+ const remaining = MAX_FILE_INJECTED_TASKS - existingFileInjected;
613
+ let injectedCount = 0;
614
+ for (const rawTask of tasks) {
615
+ if (injectedCount >= remaining) {
616
+ process.stderr.write(`Warning: file injection limit reached (${MAX_FILE_INJECTED_TASKS}), skipping remaining tasks\n`);
617
+ break;
618
+ }
619
+ // Validate required fields
620
+ if (!rawTask.id || typeof rawTask.id !== 'string') {
621
+ process.stderr.write(`Warning: skipping injected task with missing/invalid id\n`);
622
+ continue;
623
+ }
624
+ if (!rawTask.prompt || typeof rawTask.prompt !== 'string') {
625
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": missing prompt\n`);
626
+ continue;
627
+ }
628
+ if (!rawTask.agent || typeof rawTask.agent !== 'string') {
629
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": missing agent\n`);
630
+ continue;
631
+ }
632
+ // Check ID uniqueness
633
+ if (allExisting.some(t => t.id === rawTask.id)) {
634
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": ID already exists\n`);
635
+ continue;
636
+ }
637
+ // Determine phase — inject into last scheduled phase
638
+ const maxPhase = allExisting.reduce((max, t) => Math.max(max, t.phase), 0);
639
+ // Validate file paths before building the record
640
+ let validatedFiles = null;
641
+ if (rawTask.files && Array.isArray(rawTask.files)) {
642
+ try {
643
+ validatedFiles = JSON.stringify(rawTask.files.map(f => normalizePath(f)));
644
+ }
645
+ catch (err) {
646
+ process.stderr.write(`Warning: skipping injected task "${rawTask.id}": invalid file path: ${err.message}\n`);
647
+ continue;
648
+ }
649
+ }
650
+ const record = {
651
+ id: rawTask.id,
652
+ convoy_id: convoyId,
653
+ phase: maxPhase,
654
+ prompt: rawTask.prompt,
655
+ agent: rawTask.agent,
656
+ adapter: null,
657
+ model: null,
658
+ timeout_ms: typeof rawTask.timeout_ms === 'number' ? rawTask.timeout_ms : 1_800_000,
659
+ status: 'pending',
660
+ worker_id: null,
661
+ worktree: null,
662
+ output: null,
663
+ exit_code: null,
664
+ started_at: null,
665
+ finished_at: null,
666
+ retries: 0,
667
+ max_retries: typeof rawTask.max_retries === 'number' ? rawTask.max_retries : 1,
668
+ files: validatedFiles,
669
+ depends_on: null,
670
+ prompt_tokens: null,
671
+ completion_tokens: null,
672
+ total_tokens: null,
673
+ cost_usd: null,
674
+ gates: null,
675
+ on_exhausted: 'dlq',
676
+ injected: 1,
677
+ provenance: 'file-injection',
678
+ idempotency_key: null,
679
+ current_step: null,
680
+ total_steps: null,
681
+ review_level: null,
682
+ review_verdict: null,
683
+ review_tokens: null,
684
+ review_model: null,
685
+ panel_attempts: 0,
686
+ dispute_id: null,
687
+ drift_score: null,
688
+ drift_retried: 0,
689
+ outputs: null,
690
+ inputs: null,
691
+ discovered_issues: null,
692
+ };
693
+ try {
694
+ store.insertInjectedTask(record);
695
+ injectedCount++;
696
+ }
697
+ catch (err) {
698
+ process.stderr.write(`Warning: failed to inject task "${rawTask.id}": ${err.message}\n`);
699
+ }
700
+ }
701
+ if (injectedCount > 0) {
702
+ events.emit('file_injection_received', {
703
+ task_count: injectedCount,
704
+ source: injectPath,
705
+ }, { convoy_id: convoyId });
706
+ }
707
+ return injectedCount;
708
+ }
47
709
  // ── Core convoy execution ─────────────────────────────────────────────────────
48
- async function runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime) {
710
+ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath, reviewRunner) {
49
711
  const totalTasks = spec.tasks?.length ?? 0;
50
712
  let completedCount = 0;
51
713
  const activeTaskMap = new Map();
714
+ const reviewSemaphore = new ReviewSemaphore(spec.defaults?.max_concurrent_reviews ?? 3);
715
+ let reviewTokensTotal = 0;
52
716
  const taskAdapterMap = new Map();
53
717
  const healthMonitor = createHealthMonitor({
54
718
  store,
@@ -65,6 +729,17 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
65
729
  },
66
730
  });
67
731
  healthMonitor.start();
732
+ // ── Circuit breaker ────────────────────────────────────────────────────────
733
+ const circuitBreakerConfig = spec.defaults?.circuit_breaker;
734
+ const convoyRecord = store.getConvoy(convoyId);
735
+ const initialCircuitState = convoyRecord?.circuit_state ? JSON.parse(convoyRecord.circuit_state) : undefined;
736
+ const circuitBreaker = new CircuitBreakerManager(circuitBreakerConfig, initialCircuitState);
737
+ // ── Trust model ────────────────────────────────────────────────────────────
738
+ // Gate commands, hook commands, and step commands in .convoy.yml are treated
739
+ // as operator-controlled build configuration (analogous to Makefiles, CI
740
+ // configs, or package.json scripts). They are executed via sh -c and must
741
+ // NOT contain user-supplied input. The spec file itself is the trust boundary.
742
+ // ──────────────────────────────────────────────────────────────────────────
68
743
  // ── Task skipping ─────────────────────────────────────────────────────────
69
744
  function skipTask(taskId, reason, visited = new Set()) {
70
745
  if (visited.has(taskId))
@@ -101,6 +776,133 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
101
776
  }
102
777
  }
103
778
  }
779
+ function handleExhaustion(taskRecord, failureType, errorOutput) {
780
+ const exhausted = taskRecord.on_exhausted ?? 'dlq';
781
+ if (exhausted === 'dlq' || exhausted === 'stop') {
782
+ const dlqId = `dlq-${taskRecord.id}-${Date.now()}`;
783
+ // Pre-scan: build the markdown entry and check for secrets BEFORE any
784
+ // writes. This keeps the SQLite DLQ row and the Markdown file in sync —
785
+ // either both are written or neither is (MF-2 dual-write atomicity).
786
+ const { marker: dlqMarker, entry: dlqMdEntry } = buildDlqMarkdownEntry(dlqId, taskRecord, failureType, errorOutput);
787
+ const dlqScanResult = scanForSecrets(dlqMdEntry, 'AGENT-FAILURES.md');
788
+ if (!dlqScanResult.clean) {
789
+ // Block BOTH writes to maintain consistent state
790
+ events.emit('secret_leak_prevented', {
791
+ task_id: taskRecord.id,
792
+ findings_count: dlqScanResult.findings.length,
793
+ patterns: dlqScanResult.findings.map((f) => f.pattern),
794
+ context: 'dlq_dual_write',
795
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
796
+ }
797
+ else {
798
+ // Clean — proceed with both writes atomically
799
+ store.insertDlqEntry({
800
+ id: dlqId,
801
+ convoy_id: convoyId,
802
+ task_id: taskRecord.id,
803
+ agent: taskRecord.agent,
804
+ failure_type: failureType,
805
+ error_output: errorOutput,
806
+ attempts: taskRecord.retries + 1,
807
+ tokens_spent: taskRecord.total_tokens,
808
+ escalation_task_id: null,
809
+ resolved: 0,
810
+ resolution: null,
811
+ created_at: new Date().toISOString(),
812
+ resolved_at: null,
813
+ });
814
+ appendDlqMarkdownClean(dlqMarker, dlqMdEntry);
815
+ events.emit('dlq_entry_created', {
816
+ dlq_id: dlqId,
817
+ task_id: taskRecord.id,
818
+ agent: taskRecord.agent,
819
+ failure_type: failureType,
820
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
821
+ }
822
+ }
823
+ if (exhausted === 'stop') {
824
+ // Skip all remaining pending tasks + set convoy to failed
825
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending');
826
+ for (const t of allPending) {
827
+ skipTask(t.id, `on_exhausted: stop — task "${taskRecord.id}" exhausted retries`);
828
+ }
829
+ store.updateConvoyStatus(convoyId, 'failed');
830
+ }
831
+ else if (exhausted === 'dlq' || exhausted === 'skip') {
832
+ // Default behavior: cascade failure to dependents only
833
+ cascadeFailure(taskRecord.id);
834
+ }
835
+ // ── Circuit breaker: record exhaustion failure ──────────────────────────
836
+ if (circuitBreakerConfig) {
837
+ const { tripped } = circuitBreaker.recordFailure(taskRecord.agent);
838
+ try {
839
+ store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize());
840
+ }
841
+ catch { /* non-critical */ }
842
+ if (tripped) {
843
+ events.emit('circuit_breaker_tripped', {
844
+ agent: taskRecord.agent,
845
+ state: circuitBreaker.getState(taskRecord.agent),
846
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
847
+ }
848
+ }
849
+ }
850
+ // ── Hook execution ────────────────────────────────────────────────────────
851
+ async function runHooks(hooks, lifecycle, context) {
852
+ const filtered = hooks.filter(h => (h.on ?? 'post_task') === lifecycle);
853
+ for (const hook of filtered) {
854
+ if (hook.type === 'command' || hook.type === 'guard' || hook.type === 'validate') {
855
+ const cmd = hook.command;
856
+ if (!cmd)
857
+ continue;
858
+ try {
859
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
860
+ // They are NOT user-supplied and are part of the trusted build configuration.
861
+ await execFile('sh', ['-c', cmd], { cwd: context.cwd });
862
+ }
863
+ catch (err) {
864
+ const execErr = err;
865
+ const errorMsg = execErr.stderr || execErr.stdout || execErr.message || '';
866
+ return { passed: false, failedHook: hook, error: errorMsg };
867
+ }
868
+ }
869
+ else if (hook.type === 'agent') {
870
+ if (!hook.prompt)
871
+ continue;
872
+ const hookTask = {
873
+ id: `hook-${lifecycle}-${context.taskId ?? 'convoy'}-${Date.now()}`,
874
+ prompt: hook.prompt,
875
+ agent: hook.name ?? 'developer',
876
+ timeout: '10m',
877
+ depends_on: [],
878
+ files: [],
879
+ description: `Hook: ${hook.name ?? hook.type}`,
880
+ max_retries: 0,
881
+ };
882
+ try {
883
+ const hookResult = await adapter.execute(hookTask, { verbose, cwd: context.cwd });
884
+ if (!hookResult.success) {
885
+ return { passed: false, failedHook: hook, error: hookResult.output };
886
+ }
887
+ }
888
+ catch (err) {
889
+ return { passed: false, failedHook: hook, error: err.message };
890
+ }
891
+ }
892
+ else if (hook.type === 'review') {
893
+ if (!context.taskId || !reviewRunner)
894
+ continue;
895
+ const reviewTaskRecord = store.getTask(context.taskId, context.convoyId);
896
+ if (reviewTaskRecord) {
897
+ const reviewResult = await reviewRunner(reviewTaskRecord, 'fast', spec.defaults?.reviewer_model ?? 'default');
898
+ if (reviewResult.verdict !== 'pass') {
899
+ return { passed: false, failedHook: hook, error: reviewResult.feedback };
900
+ }
901
+ }
902
+ }
903
+ }
904
+ return { passed: true };
905
+ }
104
906
  // ── Single-task executor ──────────────────────────────────────────────────
105
907
  async function executeOneTask(taskRecord) {
106
908
  const workerId = `worker-${taskRecord.id}-${Date.now()}`;
@@ -119,6 +921,65 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
119
921
  }
120
922
  }
121
923
  taskAdapterMap.set(taskRecord.id, taskAdapter);
924
+ // ── Check inputs availability ────────────────────────────────────────────
925
+ if (taskRecord.inputs) {
926
+ const inputs = JSON.parse(taskRecord.inputs);
927
+ for (const input of inputs) {
928
+ const artifact = store.getArtifact(convoyId, input.name);
929
+ if (!artifact) {
930
+ store.updateTaskStatus(taskRecord.id, convoyId, 'wait-for-input');
931
+ events.emit('task_waiting_input', {
932
+ task_id: taskRecord.id,
933
+ missing_artifact: input.name,
934
+ from_task: input.from,
935
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
936
+ taskAdapterMap.delete(taskRecord.id);
937
+ return;
938
+ }
939
+ }
940
+ }
941
+ // ── Circuit breaker check ──────────────────────────────────────────────
942
+ if (circuitBreakerConfig) {
943
+ if (!circuitBreaker.canAssign(taskRecord.agent)) {
944
+ const fallback = circuitBreaker.fallback;
945
+ if (fallback) {
946
+ events.emit('circuit_breaker_fallback', {
947
+ original_agent: taskRecord.agent,
948
+ fallback_agent: fallback,
949
+ state: circuitBreaker.getState(taskRecord.agent),
950
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
951
+ }
952
+ else {
953
+ events.emit('circuit_breaker_blocked', {
954
+ agent: taskRecord.agent,
955
+ state: circuitBreaker.getState(taskRecord.agent),
956
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
957
+ }
958
+ store.updateTaskStatus(taskRecord.id, convoyId, 'skipped', {
959
+ output: `Circuit breaker open for agent "${taskRecord.agent}". ${fallback ? `No fallback available.` : `No fallback configured.`}`,
960
+ });
961
+ completedCount++;
962
+ taskAdapterMap.delete(taskRecord.id);
963
+ cascadeFailure(taskRecord.id);
964
+ return;
965
+ }
966
+ }
967
+ // ── Intelligence: circuit breaker weak-area avoidance (Phase 18.2) ─────
968
+ if (spec.defaults?.avoid_weak_agents) {
969
+ try {
970
+ const weakAreas = feedCircuitBreaker(taskRecord.agent, basePath);
971
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) : [];
972
+ const matchesWeakArea = weakAreas.some(area => taskFiles.some(f => f.toLowerCase().includes(area.toLowerCase())));
973
+ if (matchesWeakArea && taskRecord.retries === 0) {
974
+ events.emit('weak_area_skipped', { agent: taskRecord.agent, weak_areas: weakAreas, task_files: taskFiles }, { convoy_id: convoyId, task_id: taskRecord.id });
975
+ store.updateTaskStatus(taskRecord.id, convoyId, 'skipped', { output: `Agent "${taskRecord.agent}" has weak-area match for task files. Skipped by avoid_weak_agents policy.` });
976
+ completedCount++;
977
+ taskAdapterMap.delete(taskRecord.id);
978
+ return;
979
+ }
980
+ }
981
+ catch { /* non-critical */ }
982
+ }
122
983
  // Create worktree (skip for copilot adapter)
123
984
  let worktreePath = null;
124
985
  if (taskAdapter.name !== 'copilot') {
@@ -150,16 +1011,149 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
150
1011
  store.updateWorkerStatus(workerId, 'running');
151
1012
  const task = taskRecordToTask(taskRecord);
152
1013
  activeTaskMap.set(taskRecord.id, task);
1014
+ // ── Inject inputs into prompt ────────────────────────────────────────────
1015
+ if (taskRecord.inputs) {
1016
+ const inputs = JSON.parse(taskRecord.inputs);
1017
+ for (const input of inputs) {
1018
+ const artifact = store.getArtifact(convoyId, input.name);
1019
+ const templateVar = input.as ?? input.name;
1020
+ task.prompt = task.prompt.replaceAll(`{{input.${templateVar}}}`, artifact.content);
1021
+ }
1022
+ }
1023
+ // ── Scratchpad template substitution (Phase 17.1) ───────────────────────
1024
+ const scratchpadRe = /\{\{scratchpad\.([a-zA-Z0-9_.-]+)\}\}/g;
1025
+ let scratchpadMatch;
1026
+ while ((scratchpadMatch = scratchpadRe.exec(task.prompt)) !== null) {
1027
+ const spKey = scratchpadMatch[1];
1028
+ const spVal = store.getScratchpadValue(spKey);
1029
+ if (spVal !== null) {
1030
+ task.prompt = task.prompt.replaceAll(`{{scratchpad.${spKey}}}`, spVal);
1031
+ scratchpadRe.lastIndex = 0; // reset after replaceAll
1032
+ }
1033
+ }
153
1034
  process.stdout.write(` ${c.cyan('▶')} ${c.bold(`[${taskRecord.id}]`)} ${taskRecord.agent}${worktreePath ? c.dim(' (worktree)') : ''}\n`);
154
1035
  events.emit('task_started', { worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
155
1036
  const taskStartTime = Date.now();
1037
+ // ── Outbound prompt scan — NEVER send a prompt containing secrets ─────────
1038
+ const promptScan = scanForSecrets(taskRecord.prompt, `task:${taskRecord.id}`);
1039
+ if (!promptScan.clean) {
1040
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1041
+ finished_at: now(),
1042
+ output: `Secret detected in prompt — task blocked before execution.\nFindings:\n${promptScan.findings
1043
+ .map((f) => ` ${f.pattern} at line ${f.line}: ${f.snippet}`)
1044
+ .join('\n')}`,
1045
+ });
1046
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() });
1047
+ completedCount++;
1048
+ events.emit('secret_leak_prevented', {
1049
+ task_id: taskRecord.id,
1050
+ findings_count: promptScan.findings.length,
1051
+ patterns: promptScan.findings.map((f) => f.pattern),
1052
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1053
+ cascadeFailure(taskRecord.id);
1054
+ taskAdapterMap.delete(taskRecord.id);
1055
+ return;
1056
+ }
156
1057
  const timeout = makeTimeoutPromise(taskRecord.timeout_ms);
157
1058
  let result;
1059
+ // Retrieve steps from spec if defined
1060
+ const specTask = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1061
+ const steps = specTask?.steps;
1062
+ const taskHooks = specTask?.hooks ?? [];
1063
+ // ── Intelligence: inject lessons (Phase 18.1) ─────────────────────────
1064
+ if (spec.defaults?.inject_lessons !== false) {
1065
+ try {
1066
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) : [];
1067
+ const lessons = readLessons(taskRecord.agent, taskFiles, basePath);
1068
+ if (lessons.length > 0) {
1069
+ const lessonsBlock = '\n\n---\nRelevant lessons from previous sessions:\n'
1070
+ + lessons.join('\n\n')
1071
+ + '\n---\n\n';
1072
+ task.prompt = lessonsBlock + task.prompt;
1073
+ }
1074
+ }
1075
+ catch { /* non-critical */ }
1076
+ }
1077
+ // ── Intelligence: inject persistent agent identity (Phase 17.2) ────────
1078
+ const specTaskForPersistent = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1079
+ if (specTaskForPersistent?.persistent) {
1080
+ try {
1081
+ const identities = store.getAgentIdentities(taskRecord.agent, 3);
1082
+ if (identities.length > 0) {
1083
+ const contextBlock = '\n\n[Previous work context]\n'
1084
+ + identities.map(id => id.summary).join('\n\n')
1085
+ + '\n[End previous context]\n\n';
1086
+ task.prompt = contextBlock + task.prompt;
1087
+ }
1088
+ }
1089
+ catch { /* non-critical */ }
1090
+ }
1091
+ // ── Intelligence: inject discovered issues instruction (Phase 18.4) ────
1092
+ if (spec.defaults?.track_discovered_issues) {
1093
+ task.prompt = injectDiscoveredIssuesInstruction(task.prompt);
1094
+ }
1095
+ // ── pre_task hooks ────────────────────────────────────────────────────────
1096
+ if (taskHooks.length > 0) {
1097
+ const preResult = await runHooks(taskHooks, 'pre_task', {
1098
+ taskId: taskRecord.id,
1099
+ convoyId,
1100
+ cwd: worktreePath ?? basePath,
1101
+ });
1102
+ if (!preResult.passed) {
1103
+ await removeWorktree();
1104
+ const hookLabel = preResult.failedHook?.name ?? preResult.failedHook?.type ?? 'unknown';
1105
+ store.withTransaction(() => {
1106
+ store.updateTaskStatus(taskRecord.id, convoyId, 'hook-failed', {
1107
+ finished_at: now(),
1108
+ output: `pre_task hook "${hookLabel}" failed: ${preResult.error ?? ''}`,
1109
+ exit_code: 1,
1110
+ });
1111
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() });
1112
+ });
1113
+ completedCount++;
1114
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} pre_task hook failed ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1115
+ events.emit('task_failed', { reason: 'hook-failed', hook: hookLabel, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1116
+ cascadeFailure(taskRecord.id);
1117
+ taskAdapterMap.delete(taskRecord.id);
1118
+ return;
1119
+ }
1120
+ }
1121
+ // ── Symlink security scan (pre-execution) ────────────────────────────────
1122
+ const taskFiles = taskRecord.files ? JSON.parse(taskRecord.files) : [];
1123
+ if (taskFiles.length > 0 && worktreePath) {
1124
+ try {
1125
+ scanSymlinks(taskFiles, worktreePath);
1126
+ }
1127
+ catch (err) {
1128
+ await removeWorktree();
1129
+ store.withTransaction(() => {
1130
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1131
+ finished_at: now(),
1132
+ output: `Symlink security check failed: ${err.message}`,
1133
+ exit_code: 1,
1134
+ });
1135
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() });
1136
+ });
1137
+ completedCount++;
1138
+ events.emit('task_failed', { reason: 'symlink-escape', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1139
+ cascadeFailure(taskRecord.id);
1140
+ taskAdapterMap.delete(taskRecord.id);
1141
+ return;
1142
+ }
1143
+ }
158
1144
  try {
159
- result = await Promise.race([
160
- taskAdapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
161
- timeout.promise,
162
- ]);
1145
+ if (steps && steps.length > 0) {
1146
+ result = await Promise.race([
1147
+ executeSteps(taskRecord, steps, taskAdapter, worktreePath, basePath, store, convoyId, verbose),
1148
+ timeout.promise,
1149
+ ]);
1150
+ }
1151
+ else {
1152
+ result = await Promise.race([
1153
+ taskAdapter.execute(task, { verbose, cwd: worktreePath ?? basePath }),
1154
+ timeout.promise,
1155
+ ]);
1156
+ }
163
1157
  timeout.clear();
164
1158
  }
165
1159
  catch (err) {
@@ -184,12 +1178,14 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
184
1178
  await removeWorktree();
185
1179
  const freshRecord = store.getTask(taskRecord.id, convoyId);
186
1180
  if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1181
+ const contextPrefix = `Previous attempt timed out.\n\nFix the issues and try again.\n\n`;
187
1182
  store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
188
1183
  retries: freshRecord.retries + 1,
189
1184
  worker_id: null,
190
1185
  worktree: null,
191
1186
  started_at: null,
192
1187
  finished_at: null,
1188
+ prompt: contextPrefix + taskRecord.prompt,
193
1189
  });
194
1190
  store.updateWorkerStatus(workerId, 'killed', { finished_at: finishedAt });
195
1191
  process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} timed out, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
@@ -226,23 +1222,654 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
226
1222
  phase: taskRecord.phase,
227
1223
  convoy_id: convoyId,
228
1224
  }, { convoy_id: convoyId, task_id: taskRecord.id });
229
- cascadeFailure(taskRecord.id);
1225
+ handleExhaustion(freshRecord, 'timeout', result.output || null);
230
1226
  }
231
1227
  taskAdapterMap.delete(taskRecord.id);
232
1228
  return;
233
1229
  }
234
1230
  // ── Success ─────────────────────────────────────────────────────────────
235
- if (result.success) {
236
- if (worktreePath) {
1231
+ if (result.success) { // ── Per-task gates ─────────────────────────────────────────────────────
1232
+ const taskGates = taskRecord.gates ? JSON.parse(taskRecord.gates) : [];
1233
+ if (taskGates.length > 0) {
1234
+ let gateFailure = null;
1235
+ for (const command of taskGates) {
1236
+ try {
1237
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
1238
+ // They are NOT user-supplied and are part of the trusted build configuration.
1239
+ await execFile('sh', ['-c', command], { cwd: worktreePath ?? basePath });
1240
+ }
1241
+ catch (err) {
1242
+ const execErr = err;
1243
+ const code = typeof execErr.code === 'number' ? execErr.code : 1;
1244
+ const output = execErr.stderr || execErr.stdout || execErr.message || '';
1245
+ gateFailure = { command, exitCode: code, output };
1246
+ break;
1247
+ }
1248
+ }
1249
+ if (gateFailure !== null) {
1250
+ await removeWorktree();
1251
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1252
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1253
+ 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`;
1254
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1255
+ retries: freshRecord.retries + 1,
1256
+ worker_id: null,
1257
+ worktree: null,
1258
+ started_at: null,
1259
+ finished_at: null,
1260
+ prompt: contextPrefix + taskRecord.prompt,
1261
+ });
1262
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1263
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1264
+ }
1265
+ else {
1266
+ store.withTransaction(() => {
1267
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1268
+ finished_at: finishedAt,
1269
+ output: `Gate failed: ${gateFailure.command}\nExit code: ${gateFailure.exitCode}\n${gateFailure.output}`,
1270
+ exit_code: gateFailure.exitCode,
1271
+ });
1272
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1273
+ });
1274
+ completedCount++;
1275
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1276
+ events.emit('task_failed', { reason: 'gate-failed', gate: gateFailure.command, exit_code: gateFailure.exitCode, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1277
+ events.emit('session', {
1278
+ agent: taskRecord.agent,
1279
+ model: taskRecord.model ?? taskAdapter.name,
1280
+ task: taskRecord.id,
1281
+ outcome: 'failed',
1282
+ duration_min: Math.round((Date.now() - taskStartTime) / 60_000),
1283
+ files_changed: 0,
1284
+ retries: freshRecord.retries,
1285
+ convoy_id: convoyId,
1286
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1287
+ events.emit('delegation', {
1288
+ session_id: convoyId,
1289
+ agent: taskRecord.agent,
1290
+ model: taskRecord.model ?? taskAdapter.name,
1291
+ tier: 'standard',
1292
+ mechanism: 'convoy',
1293
+ outcome: 'failed',
1294
+ retries: freshRecord.retries,
1295
+ phase: taskRecord.phase,
1296
+ convoy_id: convoyId,
1297
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1298
+ handleExhaustion(freshRecord, 'gate-failed', gateFailure.output || null);
1299
+ }
1300
+ taskAdapterMap.delete(taskRecord.id);
1301
+ return;
1302
+ }
1303
+ }
1304
+ // ── Built-in gates ────────────────────────────────────────────────────
1305
+ const builtInGates = spec.defaults?.built_in_gates;
1306
+ if (builtInGates && worktreePath) {
1307
+ if (builtInGates.browser_test) {
1308
+ const specTask = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1309
+ const taskBrowserConfig = specTask?.browser_test ?? spec.defaults?.browser_test;
1310
+ if (!taskBrowserConfig) {
1311
+ process.stderr.write(`Warning: browser_test gate enabled but no browser_test config (urls) found — skipping\n`);
1312
+ }
1313
+ else {
1314
+ const browserResult = await browserTestGate({
1315
+ mcpServers: spec.defaults?.mcp_servers ?? [],
1316
+ taskConfig: taskBrowserConfig,
1317
+ worktreePath,
1318
+ approvalTimeout: spec.defaults?.mcp_server_approval_timeout,
1319
+ });
1320
+ events.emit('built_in_gate_result', { gate: 'browser_test', passed: browserResult.passed, output: browserResult.output }, { convoy_id: convoyId, task_id: taskRecord.id });
1321
+ if (!browserResult.passed) {
1322
+ await removeWorktree();
1323
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1324
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1325
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1326
+ retries: freshRecord.retries + 1,
1327
+ worker_id: null,
1328
+ worktree: null,
1329
+ started_at: null,
1330
+ finished_at: null,
1331
+ });
1332
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1333
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} browser test gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1334
+ }
1335
+ else {
1336
+ store.withTransaction(() => {
1337
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1338
+ finished_at: finishedAt,
1339
+ output: `Built-in gate (browser_test) failed:\n${browserResult.output}`,
1340
+ exit_code: 1,
1341
+ });
1342
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1343
+ });
1344
+ completedCount++;
1345
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} browser test gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1346
+ events.emit('task_failed', { reason: 'gate-failed', gate: 'browser_test', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1347
+ handleExhaustion(freshRecord, 'browser-test', browserResult.output);
1348
+ }
1349
+ taskAdapterMap.delete(taskRecord.id);
1350
+ return;
1351
+ }
1352
+ }
1353
+ }
1354
+ let changedFiles = [];
1355
+ let diff = '';
1356
+ try {
1357
+ const { stdout: filesOut } = await execFile('git', ['diff', '--name-only', `${baseBranch}..HEAD`], { cwd: worktreePath });
1358
+ changedFiles = filesOut.split('\n').filter(Boolean);
1359
+ const { stdout: diffOut } = await execFile('git', ['diff', `${baseBranch}..HEAD`], { cwd: worktreePath });
1360
+ diff = diffOut;
1361
+ }
1362
+ catch { /* no commits in worktree yet — skip */ }
1363
+ // Secret scan gate
1364
+ if (builtInGates.secret_scan && changedFiles.length > 0) {
1365
+ const scanResult = await runSecretScanGate(changedFiles, worktreePath);
1366
+ events.emit('built_in_gate_result', { gate: 'secret_scan', passed: scanResult.passed, output: scanResult.output }, { convoy_id: convoyId, task_id: taskRecord.id });
1367
+ if (!scanResult.passed) {
1368
+ await removeWorktree();
1369
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1370
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1371
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1372
+ retries: freshRecord.retries + 1,
1373
+ worker_id: null,
1374
+ worktree: null,
1375
+ started_at: null,
1376
+ finished_at: null,
1377
+ prompt: `Secret scan gate failed.\n${scanResult.output}\n\nFix the issues and try again.\n\n${taskRecord.prompt}`,
1378
+ });
1379
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1380
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} secret scan gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1381
+ }
1382
+ else {
1383
+ store.withTransaction(() => {
1384
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1385
+ finished_at: finishedAt,
1386
+ output: `Built-in gate (secret_scan) failed:\n${scanResult.output}`,
1387
+ exit_code: 1,
1388
+ });
1389
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1390
+ });
1391
+ completedCount++;
1392
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} secret scan gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1393
+ events.emit('task_failed', { reason: 'gate-failed', gate: 'secret_scan', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1394
+ handleExhaustion(freshRecord, 'secret-scan', scanResult.output);
1395
+ }
1396
+ taskAdapterMap.delete(taskRecord.id);
1397
+ return;
1398
+ }
1399
+ }
1400
+ // Blast radius gate
1401
+ if (builtInGates.blast_radius && diff) {
1402
+ const blastResult = runBlastRadiusGate(diff);
1403
+ events.emit('built_in_gate_result', { gate: 'blast_radius', level: blastResult.level, passed: blastResult.passed, output: blastResult.output }, { convoy_id: convoyId, task_id: taskRecord.id });
1404
+ if (!blastResult.passed) {
1405
+ await removeWorktree();
1406
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1407
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1408
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1409
+ retries: freshRecord.retries + 1,
1410
+ worker_id: null,
1411
+ worktree: null,
1412
+ started_at: null,
1413
+ finished_at: null,
1414
+ prompt: `Blast radius gate failed.\n${blastResult.output}\n\nFix the issues and try again.\n\n${taskRecord.prompt}`,
1415
+ });
1416
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1417
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} blast radius gate failed, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1418
+ }
1419
+ else {
1420
+ store.withTransaction(() => {
1421
+ store.updateTaskStatus(taskRecord.id, convoyId, 'gate-failed', {
1422
+ finished_at: finishedAt,
1423
+ output: `Built-in gate (blast_radius) failed:\n${blastResult.output}`,
1424
+ exit_code: 1,
1425
+ });
1426
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1427
+ });
1428
+ completedCount++;
1429
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} blast radius gate failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1430
+ events.emit('task_failed', { reason: 'gate-failed', gate: 'blast_radius', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1431
+ handleExhaustion(freshRecord, 'gate-failed', blastResult.output);
1432
+ }
1433
+ taskAdapterMap.delete(taskRecord.id);
1434
+ return;
1435
+ }
1436
+ }
1437
+ }
1438
+ // ── Drift detection ──────────────────────────────────────────────────
1439
+ const specTaskForDrift = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1440
+ const isDriftEnabled = specTaskForDrift?.detect_drift ?? spec.defaults?.detect_drift ?? false;
1441
+ if (isDriftEnabled && taskRecord.drift_retried === 0) {
1442
+ const driftResult = await detectDrift(taskRecord, taskAdapter);
1443
+ events.emit('drift_check_result', {
1444
+ task_id: taskRecord.id,
1445
+ score: driftResult.score,
1446
+ threshold: driftResult.threshold,
1447
+ explanation: driftResult.explanation,
1448
+ drifted: driftResult.drifted,
1449
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1450
+ store.updateTaskDrift(taskRecord.id, convoyId, { drift_score: driftResult.score });
1451
+ if (driftResult.drifted) {
1452
+ events.emit('drift_detected', {
1453
+ task_id: taskRecord.id,
1454
+ score: driftResult.score,
1455
+ threshold: driftResult.threshold,
1456
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1457
+ await removeWorktree();
1458
+ store.updateTaskDrift(taskRecord.id, convoyId, { drift_retried: 1 });
1459
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1460
+ worker_id: null,
1461
+ worktree: null,
1462
+ started_at: null,
1463
+ finished_at: null,
1464
+ });
1465
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1466
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} drift detected (score: ${driftResult.score.toFixed(2)}), retrying\n`);
1467
+ taskAdapterMap.delete(taskRecord.id);
1468
+ return;
1469
+ }
1470
+ }
1471
+ // ── Review pipeline ──────────────────────────────────────────────────
1472
+ const specTaskForReview = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1473
+ const taskReviewSetting = specTaskForReview?.review ?? spec.defaults?.review ?? 'auto';
1474
+ if (taskReviewSetting !== 'none') {
1475
+ // Compute diff stats from worktree
1476
+ let reviewChangedFiles = [];
1477
+ let reviewDiffLines = 0;
1478
+ if (worktreePath) {
1479
+ try {
1480
+ const { stdout: filesOut } = await execFile('git', ['diff', '--name-only', `${baseBranch}..HEAD`], { cwd: worktreePath });
1481
+ reviewChangedFiles = filesOut.split('\n').filter(Boolean);
1482
+ const { stdout: diffOut } = await execFile('git', ['diff', `${baseBranch}..HEAD`], { cwd: worktreePath });
1483
+ reviewDiffLines = diffOut.split('\n').filter(l => l.startsWith('+') || l.startsWith('-')).filter(l => !l.startsWith('+++') && !l.startsWith('---')).length;
1484
+ }
1485
+ catch { /* no commits yet */ }
1486
+ }
1487
+ const diffStats = {
1488
+ linesChanged: reviewDiffLines,
1489
+ filesChanged: reviewChangedFiles.length,
1490
+ filePaths: reviewChangedFiles,
1491
+ };
1492
+ // Determine review level
1493
+ let reviewLevel;
1494
+ if (taskReviewSetting === 'fast') {
1495
+ reviewLevel = 'fast';
1496
+ }
1497
+ else if (taskReviewSetting === 'panel') {
1498
+ reviewLevel = 'panel';
1499
+ }
1500
+ else {
1501
+ reviewLevel = evaluateReviewLevel(taskRecord, diffStats, spec.defaults?.review_heuristics, true);
1502
+ }
1503
+ const reviewerModel = spec.defaults?.reviewer_model ?? 'default';
1504
+ events.emit('review_started', { level: reviewLevel, task_id: taskRecord.id, model: reviewerModel }, { convoy_id: convoyId, task_id: taskRecord.id });
1505
+ if (reviewLevel === 'auto-pass') {
1506
+ store.updateTaskReview(taskRecord.id, convoyId, {
1507
+ review_level: 'auto-pass',
1508
+ review_verdict: 'pass',
1509
+ review_tokens: 0,
1510
+ review_model: reviewerModel,
1511
+ });
1512
+ events.emit('review_verdict', { level: 'auto-pass', verdict: 'pass', tokens: 0, model: reviewerModel, feedback_length: 0 }, { convoy_id: convoyId, task_id: taskRecord.id });
1513
+ }
1514
+ else if (reviewLevel === 'fast') {
1515
+ // Check review budget
1516
+ const reviewBudget = spec.defaults?.review_budget;
1517
+ const onBudgetExceeded = spec.defaults?.on_review_budget_exceeded ?? 'skip';
1518
+ if (reviewBudget != null && reviewTokensTotal >= reviewBudget) {
1519
+ if (onBudgetExceeded === 'stop') {
1520
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending');
1521
+ for (const t of allPending)
1522
+ skipTask(t.id, 'review_budget exceeded with on_review_budget_exceeded: stop');
1523
+ store.withTransaction(() => {
1524
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', { finished_at: finishedAt, output: 'Review budget exceeded', exit_code: 1 });
1525
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1526
+ });
1527
+ completedCount++;
1528
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} review budget exceeded (stop) ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1529
+ 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 });
1530
+ taskAdapterMap.delete(taskRecord.id);
1531
+ return;
1532
+ }
1533
+ else if (onBudgetExceeded === 'downgrade') {
1534
+ store.updateTaskReview(taskRecord.id, convoyId, { review_level: 'fast', review_verdict: 'pass', review_tokens: 0, review_model: reviewerModel });
1535
+ 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 });
1536
+ }
1537
+ else {
1538
+ // 'skip': treat as passed
1539
+ 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 });
1540
+ }
1541
+ }
1542
+ else {
1543
+ await reviewSemaphore.acquire();
1544
+ let reviewResult;
1545
+ try {
1546
+ if (reviewRunner) {
1547
+ reviewResult = await reviewRunner(taskRecord, 'fast', reviewerModel);
1548
+ }
1549
+ else {
1550
+ reviewResult = { verdict: 'pass', feedback: '', tokens: 0, model: reviewerModel };
1551
+ }
1552
+ }
1553
+ finally {
1554
+ reviewSemaphore.release();
1555
+ }
1556
+ reviewTokensTotal += reviewResult.tokens;
1557
+ store.updateTaskReview(taskRecord.id, convoyId, {
1558
+ review_level: 'fast',
1559
+ review_verdict: reviewResult.verdict,
1560
+ review_tokens: reviewResult.tokens,
1561
+ review_model: reviewResult.model,
1562
+ });
1563
+ store.updateConvoyReviewTokens(convoyId, reviewTokensTotal);
1564
+ 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 });
1565
+ if (reviewResult.verdict === 'block') {
1566
+ await removeWorktree();
1567
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1568
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1569
+ const contextPrefix = `Previous attempt was blocked by review.\nFeedback:\n${reviewResult.feedback}\n\nFix the issues and try again.\n\n`;
1570
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1571
+ retries: freshRecord.retries + 1,
1572
+ worker_id: null,
1573
+ worktree: null,
1574
+ started_at: null,
1575
+ finished_at: null,
1576
+ prompt: contextPrefix + taskRecord.prompt,
1577
+ });
1578
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1579
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} review blocked, retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1580
+ taskAdapterMap.delete(taskRecord.id);
1581
+ return;
1582
+ }
1583
+ else {
1584
+ store.withTransaction(() => {
1585
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', {
1586
+ finished_at: finishedAt,
1587
+ output: `Review blocked: ${reviewResult.feedback}`,
1588
+ exit_code: 1,
1589
+ });
1590
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1591
+ });
1592
+ completedCount++;
1593
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} review blocked ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1594
+ events.emit('task_failed', { reason: 'review-blocked', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1595
+ handleExhaustion(freshRecord, 'review-blocked', reviewResult.feedback || null);
1596
+ taskAdapterMap.delete(taskRecord.id);
1597
+ return;
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ else {
1603
+ // panel: 3 concurrent reviewer calls, majority vote
1604
+ await reviewSemaphore.acquire();
1605
+ let panelResults;
1606
+ try {
1607
+ const noopRunner = (_t, _l, m) => Promise.resolve({ verdict: 'pass', feedback: '', tokens: 0, model: m });
1608
+ const runner = reviewRunner ?? noopRunner;
1609
+ panelResults = await Promise.all([
1610
+ runner(taskRecord, 'panel', reviewerModel),
1611
+ runner(taskRecord, 'panel', reviewerModel),
1612
+ runner(taskRecord, 'panel', reviewerModel),
1613
+ ]);
1614
+ }
1615
+ finally {
1616
+ reviewSemaphore.release();
1617
+ }
1618
+ const panelPasses = panelResults.filter(r => r.verdict === 'pass').length;
1619
+ const panelBlocks = panelResults.filter(r => r.verdict === 'block').length;
1620
+ const totalPanelTokens = panelResults.reduce((sum, r) => sum + r.tokens, 0);
1621
+ reviewTokensTotal += totalPanelTokens;
1622
+ const freshForPanel = store.getTask(taskRecord.id, convoyId);
1623
+ store.updateTaskReview(taskRecord.id, convoyId, {
1624
+ review_level: 'panel',
1625
+ review_verdict: panelPasses >= 2 ? 'pass' : 'block',
1626
+ review_tokens: totalPanelTokens,
1627
+ review_model: reviewerModel,
1628
+ panel_attempts: freshForPanel.panel_attempts + 1,
1629
+ });
1630
+ if (totalPanelTokens > 0)
1631
+ store.updateConvoyReviewTokens(convoyId, reviewTokensTotal);
1632
+ 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 });
1633
+ if (panelBlocks >= 2) {
1634
+ const blockFeedback = panelResults.filter(r => r.verdict === 'block').map(r => r.feedback).join('\n\n---\n\n');
1635
+ await removeWorktree();
1636
+ // Check for dispute trigger
1637
+ const updatedTask = store.getTask(taskRecord.id, convoyId);
1638
+ if (updatedTask.panel_attempts >= 3) {
1639
+ const disputeId = `dispute-${taskRecord.id}-${Date.now()}`;
1640
+ const onDispute = spec.defaults?.on_dispute ?? 'stop';
1641
+ store.updateTaskDisputeStatus(taskRecord.id, convoyId, 'disputed', disputeId);
1642
+ writeDisputeToMarkdown(disputeId, convoyId, taskRecord, panelResults, events);
1643
+ events.emit('dispute_opened', {
1644
+ dispute_id: disputeId,
1645
+ task_id: taskRecord.id,
1646
+ agent: taskRecord.agent,
1647
+ panel_attempts: updatedTask.panel_attempts,
1648
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1649
+ if (onDispute === 'stop') {
1650
+ const allPending = store.getTasksByConvoy(convoyId).filter(t => t.status === 'pending');
1651
+ for (const t of allPending) {
1652
+ skipTask(t.id, `on_dispute: stop — task "${taskRecord.id}" disputed`);
1653
+ }
1654
+ }
1655
+ completedCount++;
1656
+ process.stdout.write(` ${c.red('⚡')} ${c.bold(`[${taskRecord.id}]`)} disputed after ${updatedTask.panel_attempts} panel attempts\n`);
1657
+ taskAdapterMap.delete(taskRecord.id);
1658
+ return;
1659
+ }
1660
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1661
+ if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
1662
+ 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`;
1663
+ store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
1664
+ retries: freshRecord.retries + 1,
1665
+ worker_id: null,
1666
+ worktree: null,
1667
+ started_at: null,
1668
+ finished_at: null,
1669
+ prompt: contextPrefix + taskRecord.prompt,
1670
+ });
1671
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1672
+ process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} panel blocked (${panelBlocks}/3), retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
1673
+ taskAdapterMap.delete(taskRecord.id);
1674
+ return;
1675
+ }
1676
+ else {
1677
+ store.withTransaction(() => {
1678
+ store.updateTaskStatus(taskRecord.id, convoyId, 'review-blocked', {
1679
+ finished_at: finishedAt,
1680
+ output: `Panel review blocked (${panelBlocks}/3): ${blockFeedback}`,
1681
+ exit_code: 1,
1682
+ });
1683
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1684
+ });
1685
+ completedCount++;
1686
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} panel blocked ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1687
+ events.emit('task_failed', { reason: 'review-blocked', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1688
+ handleExhaustion(freshRecord, 'review-blocked', blockFeedback || null);
1689
+ taskAdapterMap.delete(taskRecord.id);
1690
+ return;
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1695
+ // ── Intelligence: check discovered issues (Phase 18.4) ─────────────
1696
+ if (spec.defaults?.track_discovered_issues) {
237
1697
  try {
238
- await mergeQueue.merge(worktreePath, `convoy-${workerId}`, baseBranch);
1698
+ checkDiscoveredIssues(taskRecord.id, events, convoyId, worktreePath ?? basePath);
1699
+ }
1700
+ catch { /* non-critical */ }
1701
+ }
1702
+ // ── post_task hooks ───────────────────────────────────────────────────
1703
+ if (taskHooks.length > 0) {
1704
+ const postResult = await runHooks(taskHooks, 'post_task', {
1705
+ taskId: taskRecord.id,
1706
+ convoyId,
1707
+ cwd: worktreePath ?? basePath,
1708
+ });
1709
+ if (!postResult.passed) {
1710
+ await removeWorktree();
1711
+ const hookLabel = postResult.failedHook?.name ?? postResult.failedHook?.type ?? 'unknown';
1712
+ store.withTransaction(() => {
1713
+ store.updateTaskStatus(taskRecord.id, convoyId, 'hook-failed', {
1714
+ finished_at: finishedAt,
1715
+ output: `post_task hook "${hookLabel}" failed: ${postResult.error ?? ''}`,
1716
+ exit_code: 1,
1717
+ });
1718
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1719
+ });
1720
+ completedCount++;
1721
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} post_task hook failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1722
+ events.emit('task_failed', { reason: 'hook-failed', hook: hookLabel, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1723
+ cascadeFailure(taskRecord.id);
1724
+ taskAdapterMap.delete(taskRecord.id);
1725
+ return;
1726
+ }
1727
+ }
1728
+ // ── Symlink security scan (post-execution) ───────────────────────────
1729
+ if (taskFiles.length > 0 && worktreePath) {
1730
+ try {
1731
+ scanNewSymlinks(worktreePath, taskFiles);
239
1732
  }
240
1733
  catch (err) {
241
- if (verbose) {
242
- process.stderr.write(`Warning: merge failed for ${taskRecord.id}: ${err.message}\n`);
1734
+ await removeWorktree();
1735
+ store.withTransaction(() => {
1736
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1737
+ finished_at: finishedAt,
1738
+ output: `Post-execution symlink security check failed: ${err.message}`,
1739
+ exit_code: 1,
1740
+ });
1741
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
1742
+ });
1743
+ completedCount++;
1744
+ events.emit('task_failed', { reason: 'symlink-escape-post', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1745
+ cascadeFailure(taskRecord.id);
1746
+ taskAdapterMap.delete(taskRecord.id);
1747
+ return;
1748
+ }
1749
+ }
1750
+ if (worktreePath) {
1751
+ let mergeAttempt = 0;
1752
+ const maxMergeAttempts = 2;
1753
+ let merged = false;
1754
+ while (mergeAttempt < maxMergeAttempts && !merged) {
1755
+ try {
1756
+ await mergeQueue.merge(worktreePath, `convoy-${workerId}`, baseBranch);
1757
+ merged = true;
1758
+ }
1759
+ catch (err) {
1760
+ if (err instanceof MergeConflictError) {
1761
+ mergeAttempt++;
1762
+ events.emit('merge_conflict_detected', {
1763
+ attempt: mergeAttempt,
1764
+ conflicting_files: err.conflictingFiles,
1765
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1766
+ if (mergeAttempt >= maxMergeAttempts) {
1767
+ events.emit('merge_conflict_failed', {
1768
+ attempts: mergeAttempt,
1769
+ conflicting_files: err.conflictingFiles,
1770
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1771
+ const freshRecord = store.getTask(taskRecord.id, convoyId);
1772
+ store.withTransaction(() => {
1773
+ store.updateTaskStatus(taskRecord.id, convoyId, 'failed', {
1774
+ finished_at: now(),
1775
+ output: `Merge conflict could not be resolved after ${mergeAttempt} attempts. Files: ${err.conflictingFiles.join(', ')}`,
1776
+ exit_code: 1,
1777
+ });
1778
+ store.updateWorkerStatus(workerId, 'failed', { finished_at: now() });
1779
+ });
1780
+ completedCount++;
1781
+ process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} merge conflict unresolved ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
1782
+ events.emit('task_failed', { reason: 'merge-conflict', worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
1783
+ cascadeFailure(taskRecord.id);
1784
+ handleExhaustion(freshRecord, 'merge-conflict', err.conflictingFiles.join(', '));
1785
+ break;
1786
+ }
1787
+ // Per spec: backoff on second attempt (unreachable with maxMergeAttempts=2 but follows spec)
1788
+ if (mergeAttempt === 2) {
1789
+ await new Promise(resolve => setTimeout(resolve, 30_000));
1790
+ }
1791
+ // Inject a resolution task
1792
+ const fileHash = createHash('sha256')
1793
+ .update(err.conflictingFiles.sort().join(','))
1794
+ .digest('hex')
1795
+ .slice(0, 12);
1796
+ const idempotencyKey = `merge-conflict:${taskRecord.phase}:${fileHash}`;
1797
+ const resolutionTaskId = `merge-fix-${taskRecord.id}-${mergeAttempt}`;
1798
+ const conflictPrompt = `Resolve merge conflicts in: ${err.conflictingFiles.join(', ')}. Ensure no conflict markers remain (<<<<<<<, =======, >>>>>>>), syntax is valid, no duplicate imports.`;
1799
+ const resolutionRecord = {
1800
+ id: resolutionTaskId,
1801
+ convoy_id: convoyId,
1802
+ phase: taskRecord.phase,
1803
+ prompt: conflictPrompt,
1804
+ agent: taskRecord.agent,
1805
+ adapter: null,
1806
+ model: null,
1807
+ timeout_ms: 600_000,
1808
+ status: 'pending',
1809
+ worker_id: null,
1810
+ worktree: null,
1811
+ output: null,
1812
+ exit_code: null,
1813
+ started_at: null,
1814
+ finished_at: null,
1815
+ retries: 0,
1816
+ max_retries: 1,
1817
+ files: JSON.stringify(err.conflictingFiles),
1818
+ depends_on: null,
1819
+ prompt_tokens: null,
1820
+ completion_tokens: null,
1821
+ total_tokens: null,
1822
+ cost_usd: null,
1823
+ gates: null,
1824
+ on_exhausted: 'dlq',
1825
+ injected: 1,
1826
+ provenance: 'merge-conflict',
1827
+ idempotency_key: idempotencyKey,
1828
+ current_step: null,
1829
+ total_steps: null,
1830
+ review_level: null,
1831
+ review_verdict: null,
1832
+ review_tokens: null,
1833
+ review_model: null,
1834
+ panel_attempts: 0,
1835
+ dispute_id: null,
1836
+ drift_score: null,
1837
+ drift_retried: 0,
1838
+ outputs: null,
1839
+ inputs: null,
1840
+ discovered_issues: null,
1841
+ };
1842
+ store.insertInjectedTask(resolutionRecord);
1843
+ const storedResolutionRecord = store.getTask(resolutionTaskId, convoyId);
1844
+ await executeOneTask(storedResolutionRecord);
1845
+ // Next loop iteration will retry the merge
1846
+ }
1847
+ else {
1848
+ // Non-conflict merge error — log warning and continue to done path
1849
+ if (verbose) {
1850
+ process.stderr.write(`Warning: merge failed for ${taskRecord.id}: ${err.message}\n`);
1851
+ }
1852
+ merged = true; // Preserve original behavior: continue despite error
1853
+ break;
1854
+ }
243
1855
  }
244
1856
  }
245
1857
  await removeWorktree();
1858
+ if (!merged) {
1859
+ taskAdapterMap.delete(taskRecord.id);
1860
+ return;
1861
+ }
1862
+ // ── Intelligence: update expertise post-merge (Phase 18.2) ─────────
1863
+ try {
1864
+ updateExpertise(taskRecord.agent, { taskId: taskRecord.id, success: true, retries: taskRecord.retries, files: taskRecord.files ? JSON.parse(taskRecord.files) : [] }, basePath);
1865
+ }
1866
+ catch { /* non-critical */ }
1867
+ // ── Intelligence: build knowledge graph post-merge (Phase 18.3) ────
1868
+ try {
1869
+ const { stdout: diffOut } = await execFile('git', ['diff', 'HEAD~1'], { cwd: basePath });
1870
+ buildKnowledgeGraph(diffOut, convoyId, basePath);
1871
+ }
1872
+ catch { /* non-critical */ }
246
1873
  }
247
1874
  const usageExtra = {};
248
1875
  if (result.usage) {
@@ -253,6 +1880,80 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
253
1880
  if (result.usage.total_tokens != null)
254
1881
  usageExtra.total_tokens = result.usage.total_tokens;
255
1882
  }
1883
+ // ── Capture outputs as artifacts ────────────────────────────────────────
1884
+ if (taskRecord.outputs) {
1885
+ const outputs = JSON.parse(taskRecord.outputs);
1886
+ for (const output of outputs) {
1887
+ let content;
1888
+ if (output.type === 'summary') {
1889
+ content = result.output.slice(-4096);
1890
+ }
1891
+ else if (output.type === 'json') {
1892
+ const jsonMatch = result.output.match(/```json\n([\s\S]*?)```/);
1893
+ content = jsonMatch ? jsonMatch[1].trim() : result.output;
1894
+ }
1895
+ else {
1896
+ content = result.output;
1897
+ }
1898
+ try {
1899
+ store.insertArtifact({
1900
+ id: `artifact-${taskRecord.id}-${output.name}-${Date.now()}`,
1901
+ convoy_id: convoyId,
1902
+ task_id: taskRecord.id,
1903
+ name: output.name,
1904
+ type: output.type,
1905
+ content,
1906
+ created_at: new Date().toISOString(),
1907
+ });
1908
+ }
1909
+ catch (err) {
1910
+ if (err instanceof ConvoyArtifactLimitError) {
1911
+ events.emit('artifact_limit_reached', {
1912
+ task_id: taskRecord.id,
1913
+ artifact_name: output.name,
1914
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1915
+ }
1916
+ else {
1917
+ throw err;
1918
+ }
1919
+ }
1920
+ }
1921
+ }
1922
+ // ── Intelligence: capture persistent agent identity (Phase 17.2) ─────
1923
+ const specTaskForCapture = (spec.tasks ?? []).find(t => t.id === taskRecord.id);
1924
+ if (specTaskForCapture?.persistent && result.output) {
1925
+ try {
1926
+ // Extract last 300 words, cap at 4KB
1927
+ const words = result.output.split(/\s+/);
1928
+ const lastWords = words.slice(-300).join(' ');
1929
+ let summary = lastWords.length > 4096 ? lastWords.slice(-4096) : lastWords;
1930
+ // Secret-scan the summary before storing
1931
+ const summaryScan = scanForSecrets(summary, `identity:${taskRecord.id}`);
1932
+ if (summaryScan.clean) {
1933
+ store.insertAgentIdentity({
1934
+ id: `identity-${taskRecord.id}-${Date.now()}`,
1935
+ agent: taskRecord.agent,
1936
+ convoy_id: convoyId,
1937
+ task_id: taskRecord.id,
1938
+ summary,
1939
+ created_at: new Date().toISOString(),
1940
+ retention_days: 90,
1941
+ });
1942
+ events.emit('agent_identity_captured', {
1943
+ agent: taskRecord.agent,
1944
+ summary_length: summary.length,
1945
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1946
+ }
1947
+ else {
1948
+ events.emit('agent_identity_rejected', {
1949
+ agent: taskRecord.agent,
1950
+ reason: 'secrets_detected',
1951
+ findings_count: summaryScan.findings.length,
1952
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
1953
+ }
1954
+ }
1955
+ catch { /* non-critical */ }
1956
+ }
256
1957
  store.withTransaction(() => {
257
1958
  store.updateTaskStatus(taskRecord.id, convoyId, 'done', {
258
1959
  finished_at: finishedAt,
@@ -262,6 +1963,28 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
262
1963
  });
263
1964
  store.updateWorkerStatus(workerId, 'done', { finished_at: finishedAt });
264
1965
  });
1966
+ // ── Circuit breaker: record success ────────────────────────────────────
1967
+ if (circuitBreakerConfig) {
1968
+ circuitBreaker.recordSuccess(taskRecord.agent);
1969
+ try {
1970
+ store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize());
1971
+ }
1972
+ catch { /* non-critical */ }
1973
+ }
1974
+ // ── Intelligence: capture retry lesson (Phase 18.1) ─────────────────
1975
+ if (taskRecord.retries > 0 && spec.defaults?.inject_lessons !== false) {
1976
+ try {
1977
+ captureLessons({
1978
+ title: `Retry success for ${taskRecord.agent} on ${taskRecord.id}`,
1979
+ category: 'convoy',
1980
+ agent: taskRecord.agent,
1981
+ problem: `Task ${taskRecord.id} required ${taskRecord.retries} retries`,
1982
+ solution: 'Succeeded after retry with adjusted approach',
1983
+ files: taskRecord.files ? JSON.parse(taskRecord.files) : undefined,
1984
+ }, basePath);
1985
+ }
1986
+ catch { /* non-critical */ }
1987
+ }
265
1988
  completedCount++;
266
1989
  process.stdout.write(` ${c.green('✓')} ${c.bold(`[${taskRecord.id}]`)} ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
267
1990
  events.emit('task_done', { exit_code: result.exitCode, worker_id: workerId }, { convoy_id: convoyId, task_id: taskRecord.id, worker_id: workerId });
@@ -295,12 +2018,15 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
295
2018
  await removeWorktree();
296
2019
  const freshRecord = store.getTask(taskRecord.id, convoyId);
297
2020
  if (freshRecord.retries < freshRecord.max_retries && spec.on_failure !== 'stop') {
2021
+ const failedOutput = result.output || '(no output)';
2022
+ const contextPrefix = `Previous attempt failed.\nExit code: ${result.exitCode}\nError output:\n${failedOutput}\n\nFix the issues and try again.\n\n`;
298
2023
  store.updateTaskStatus(taskRecord.id, convoyId, 'pending', {
299
2024
  retries: freshRecord.retries + 1,
300
2025
  worker_id: null,
301
2026
  worktree: null,
302
2027
  started_at: null,
303
2028
  finished_at: null,
2029
+ prompt: contextPrefix + taskRecord.prompt,
304
2030
  });
305
2031
  store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
306
2032
  process.stdout.write(` ${c.yellow('⟳')} ${c.bold(`[${taskRecord.id}]`)} retry ${freshRecord.retries + 1}/${freshRecord.max_retries}\n`);
@@ -314,6 +2040,25 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
314
2040
  });
315
2041
  store.updateWorkerStatus(workerId, 'failed', { finished_at: finishedAt });
316
2042
  });
2043
+ // ── Intelligence: record failure in expertise (Phase 18.2) ──────────
2044
+ try {
2045
+ updateExpertise(taskRecord.agent, { taskId: taskRecord.id, success: false, retries: freshRecord.retries, files: taskRecord.files ? JSON.parse(taskRecord.files) : [] }, basePath);
2046
+ }
2047
+ catch { /* non-critical */ }
2048
+ // ── Circuit breaker: record failure ────────────────────────────────────
2049
+ if (circuitBreakerConfig) {
2050
+ const { tripped } = circuitBreaker.recordFailure(taskRecord.agent);
2051
+ try {
2052
+ store.updateConvoyCircuitState(convoyId, circuitBreaker.serialize());
2053
+ }
2054
+ catch { /* non-critical */ }
2055
+ if (tripped) {
2056
+ events.emit('circuit_breaker_tripped', {
2057
+ agent: taskRecord.agent,
2058
+ state: circuitBreaker.getState(taskRecord.agent),
2059
+ }, { convoy_id: convoyId, task_id: taskRecord.id });
2060
+ }
2061
+ }
317
2062
  completedCount++;
318
2063
  process.stdout.write(` ${c.red('✗')} ${c.bold(`[${taskRecord.id}]`)} failed ${elapsed} ${c.dim(`[${completedCount}/${totalTasks}]`)}\n`);
319
2064
  if (verbose) {
@@ -342,26 +2087,52 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
342
2087
  phase: taskRecord.phase,
343
2088
  convoy_id: convoyId,
344
2089
  }, { convoy_id: convoyId, task_id: taskRecord.id });
345
- cascadeFailure(taskRecord.id);
2090
+ handleExhaustion(freshRecord, 'error', result.output || null);
346
2091
  }
347
2092
  taskAdapterMap.delete(taskRecord.id);
348
2093
  }
349
2094
  // ── Main execution loop ───────────────────────────────────────────────────
350
2095
  let lastPhase = -1;
2096
+ const isSwarmMode = spec.concurrency === 'auto';
2097
+ const maxSwarmConcurrency = spec.defaults?.max_swarm_concurrency ?? 8;
2098
+ let lastInjectPoll = 0;
2099
+ const INJECT_POLL_INTERVAL = 2000; // 2 seconds
351
2100
  try {
352
2101
  let ready = store.getReadyTasks(convoyId);
353
- const concurrency = spec.concurrency ?? 1;
354
2102
  while (ready.length > 0) {
2103
+ // Compute effective concurrency for this phase
2104
+ const effectiveConcurrency = isSwarmMode
2105
+ ? Math.min(ready.length, maxSwarmConcurrency)
2106
+ : (typeof spec.concurrency === 'number' ? spec.concurrency : 1);
355
2107
  for (const t of ready) {
356
2108
  if (t.phase !== lastPhase) {
357
2109
  lastPhase = t.phase;
358
2110
  const tasksInPhase = ready.filter(r => r.phase === t.phase);
359
2111
  const ids = tasksInPhase.map(r => r.id).join(', ');
360
2112
  process.stdout.write(`\n ${c.bold(`Phase ${t.phase + 1}:`)} ${c.dim(ids)}\n`);
2113
+ if (isSwarmMode) {
2114
+ events.emit('swarm_concurrency_update', {
2115
+ phase: t.phase,
2116
+ pending_count: ready.length,
2117
+ effective_concurrency: effectiveConcurrency,
2118
+ }, { convoy_id: convoyId });
2119
+ }
361
2120
  }
362
2121
  }
363
- for (let i = 0; i < ready.length; i += concurrency) {
364
- await Promise.all(ready.slice(i, i + concurrency).map(t => executeOneTask(t)));
2122
+ for (let i = 0; i < ready.length; i += effectiveConcurrency) {
2123
+ // Poll for file-based injection between batches
2124
+ const now = Date.now();
2125
+ if (now - lastInjectPoll >= INJECT_POLL_INTERVAL) {
2126
+ pollInjectFile(convoyId, store, events, basePath);
2127
+ lastInjectPoll = now;
2128
+ }
2129
+ await Promise.all(ready.slice(i, i + effectiveConcurrency).map(t => executeOneTask(t)));
2130
+ }
2131
+ // Reset wait-for-input tasks to pending so they are re-evaluated after
2132
+ // upstream artifacts may have been captured in this batch
2133
+ const waitingTasks = store.getTasksByConvoy(convoyId).filter(t => t.status === 'wait-for-input');
2134
+ for (const wt of waitingTasks) {
2135
+ store.updateTaskStatus(wt.id, convoyId, 'pending');
365
2136
  }
366
2137
  ready = store.getReadyTasks(convoyId);
367
2138
  }
@@ -380,6 +2151,8 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
380
2151
  process.stdout.write(`\n ${c.bold(gateAttempt === 0 ? 'Gates:' : `Gates (retry ${gateAttempt}/${maxGateRetries}):`)}\n`);
381
2152
  for (const command of spec.gates) {
382
2153
  try {
2154
+ // SECURITY: Gate/hook commands come from the .convoy.yml spec file, which is operator-controlled.
2155
+ // They are NOT user-supplied and are part of the trusted build configuration.
383
2156
  await execFile('sh', ['-c', command], { cwd: basePath });
384
2157
  gateResults.push({ command, exitCode: 0, passed: true });
385
2158
  process.stdout.write(` ${c.green('✓')} ${c.dim(command)}\n`);
@@ -425,12 +2198,41 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
425
2198
  break; // Don't retry if the fix task itself fails
426
2199
  }
427
2200
  }
2201
+ // ── post_convoy hooks ─────────────────────────────────────────────────────
2202
+ const specLevelHooks = spec.hooks ?? [];
2203
+ if (specLevelHooks.length > 0) {
2204
+ const postConvoyResult = await runHooks(specLevelHooks, 'post_convoy', {
2205
+ convoyId,
2206
+ cwd: basePath,
2207
+ });
2208
+ if (!postConvoyResult.passed) {
2209
+ const hookLabel = postConvoyResult.failedHook?.name ?? postConvoyResult.failedHook?.type ?? 'unknown';
2210
+ events.emit('post_convoy_hook_failed', {
2211
+ hook: hookLabel,
2212
+ error: postConvoyResult.error,
2213
+ }, { convoy_id: convoyId });
2214
+ process.stdout.write(` ${c.red('✗')} post_convoy hook "${hookLabel}" failed\n`);
2215
+ }
2216
+ }
2217
+ // ── Intelligence: post-convoy consolidation ──────────────────────────────
2218
+ if (spec.defaults?.inject_lessons !== false) {
2219
+ try {
2220
+ consolidateLessons(basePath);
2221
+ }
2222
+ catch { /* non-critical */ }
2223
+ }
2224
+ if (spec.defaults?.track_discovered_issues) {
2225
+ try {
2226
+ consolidateIssues(basePath);
2227
+ }
2228
+ catch { /* non-critical */ }
2229
+ }
428
2230
  // ── Final status & summary ────────────────────────────────────────────────
429
2231
  const allTasksFinal = store.getTasksByConvoy(convoyId);
430
2232
  const summary = {
431
2233
  total: allTasksFinal.length,
432
2234
  done: allTasksFinal.filter(t => t.status === 'done').length,
433
- failed: allTasksFinal.filter(t => t.status === 'failed').length,
2235
+ failed: allTasksFinal.filter(t => t.status === 'failed' || t.status === 'gate-failed' || t.status === 'review-blocked' || t.status === 'disputed').length,
434
2236
  skipped: allTasksFinal.filter(t => t.status === 'skipped').length,
435
2237
  timedOut: allTasksFinal.filter(t => t.status === 'timed-out').length,
436
2238
  };
@@ -451,6 +2253,18 @@ async function runConvoy(convoyId, spec, adapter, store, events, wtManager, merg
451
2253
  finished_at: new Date().toISOString(),
452
2254
  total_tokens: convoyTotalTokens,
453
2255
  });
2256
+ // Run convoy guard checks
2257
+ const guardResult = runConvoyGuard(store, convoyId, wtManager, ndjsonPath, spec.guard);
2258
+ if (guardResult.warnings.length > 0) {
2259
+ process.stdout.write(`\n ${c.yellow('Guard warnings:')}\n`);
2260
+ for (const w of guardResult.warnings) {
2261
+ process.stdout.write(` ${c.dim('⚠')} ${w}\n`);
2262
+ }
2263
+ events.emit('convoy_guard', {
2264
+ passed: guardResult.passed,
2265
+ warnings: guardResult.warnings,
2266
+ }, { convoy_id: convoyId });
2267
+ }
454
2268
  return {
455
2269
  convoyId,
456
2270
  status: finalStatus,
@@ -481,9 +2295,45 @@ export function createConvoyEngine(options) {
481
2295
  const convoyId = `convoy-${startTime}`;
482
2296
  const specHash = createHash('sha256').update(specYaml).digest('hex');
483
2297
  const baseBranch = spec.branch ?? (await getCurrentBranch());
2298
+ // Ensure target branch exists before acquiring any locks.
2299
+ // Uses _ensureBranch injection so callers/tests can override.
2300
+ if (spec.branch !== undefined) {
2301
+ const branchFn = options._ensureBranch ?? ensureBranch;
2302
+ await branchFn(spec.branch, basePath);
2303
+ }
484
2304
  mkdirSync(dirname(dbPath), { recursive: true });
2305
+ const lockDb = new DatabaseSync(dbPath);
2306
+ lockDb.exec('PRAGMA journal_mode = WAL');
2307
+ lockDb.exec(`CREATE TABLE IF NOT EXISTS engine_lock (
2308
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2309
+ pid INTEGER NOT NULL,
2310
+ hostname TEXT NOT NULL,
2311
+ started_at TEXT NOT NULL,
2312
+ last_heartbeat TEXT NOT NULL
2313
+ )`);
2314
+ const lock = (() => {
2315
+ try {
2316
+ return acquireEngineLock(lockDb, dbPath);
2317
+ }
2318
+ catch (err) {
2319
+ lockDb.close();
2320
+ throw err;
2321
+ }
2322
+ })();
2323
+ const versionRow = lockDb.prepare('SELECT sqlite_version() as v').get();
2324
+ const [major, minor] = versionRow.v.split('.').map(Number);
2325
+ if (major < 3 || (major === 3 && minor < 35)) {
2326
+ lock.release();
2327
+ lockDb.close();
2328
+ throw new Error(`SQLite version ${versionRow.v} is too old. Requires >= 3.35.0`);
2329
+ }
2330
+ lock.startHeartbeat();
485
2331
  const store = createConvoyStore(dbPath);
486
- const events = createEventEmitter(store, options.logsDir);
2332
+ const ndjsonPath = options.logsDir
2333
+ ? join(options.logsDir, 'convoy-events.ndjson')
2334
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson');
2335
+ mkdirSync(dirname(ndjsonPath), { recursive: true });
2336
+ const events = createEventEmitter(store, { ndjsonPath });
487
2337
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath);
488
2338
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath);
489
2339
  let result;
@@ -500,6 +2350,15 @@ export function createConvoyEngine(options) {
500
2350
  });
501
2351
  const tasks = spec.tasks ?? [];
502
2352
  const phases = buildPhases(tasks);
2353
+ // Validate file partitions before inserting tasks
2354
+ const partitionResult = validateFilePartitions(tasks, phases);
2355
+ if (!partitionResult.valid) {
2356
+ const conflictSummary = partitionResult.conflicts
2357
+ .map((cf) => `Phase ${cf.phase}: tasks "${cf.taskA}" and "${cf.taskB}" overlap on [${cf.overlapping.join(', ')}]`)
2358
+ .join('\n');
2359
+ events.emit('file_partition_conflict', { conflicts: partitionResult.conflicts }, { convoy_id: convoyId });
2360
+ throw new Error(`File partition conflicts detected:\n${conflictSummary}`);
2361
+ }
503
2362
  for (let phaseIdx = 0; phaseIdx < phases.length; phaseIdx++) {
504
2363
  for (const task of phases[phaseIdx]) {
505
2364
  store.insertTask({
@@ -516,27 +2375,63 @@ export function createConvoyEngine(options) {
516
2375
  max_retries: task.max_retries,
517
2376
  files: task.files.length > 0 ? JSON.stringify(task.files) : null,
518
2377
  depends_on: task.depends_on.length > 0 ? JSON.stringify(task.depends_on) : null,
2378
+ gates: task.gates && task.gates.length > 0 ? JSON.stringify(task.gates) : null,
2379
+ outputs: task.outputs && task.outputs.length > 0 ? JSON.stringify(task.outputs) : null,
2380
+ inputs: task.inputs && task.inputs.length > 0 ? JSON.stringify(task.inputs) : null,
519
2381
  });
520
2382
  }
521
2383
  }
522
2384
  store.updateConvoyStatus(convoyId, 'running', { started_at: new Date().toISOString() });
523
2385
  events.emit('convoy_started', { name: spec.name }, { convoy_id: convoyId });
524
- result = await runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime);
2386
+ result = await runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath, options._reviewRunner);
525
2387
  }
526
2388
  finally {
527
2389
  try {
528
2390
  await exportConvoyToNdjson(store, convoyId, options.logsDir);
529
2391
  }
530
2392
  catch { /* silent */ }
2393
+ events.close();
531
2394
  store.close();
2395
+ lock.release();
2396
+ lockDb.close();
532
2397
  }
533
2398
  return result;
534
2399
  }
535
2400
  async function resume(convoyId) {
536
2401
  const startTime = Date.now();
537
2402
  mkdirSync(dirname(dbPath), { recursive: true });
2403
+ const lockDb = new DatabaseSync(dbPath);
2404
+ lockDb.exec('PRAGMA journal_mode = WAL');
2405
+ lockDb.exec(`CREATE TABLE IF NOT EXISTS engine_lock (
2406
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2407
+ pid INTEGER NOT NULL,
2408
+ hostname TEXT NOT NULL,
2409
+ started_at TEXT NOT NULL,
2410
+ last_heartbeat TEXT NOT NULL
2411
+ )`);
2412
+ const lock = (() => {
2413
+ try {
2414
+ return acquireEngineLock(lockDb, dbPath);
2415
+ }
2416
+ catch (err) {
2417
+ lockDb.close();
2418
+ throw err;
2419
+ }
2420
+ })();
2421
+ const versionRow = lockDb.prepare('SELECT sqlite_version() as v').get();
2422
+ const [major, minor] = versionRow.v.split('.').map(Number);
2423
+ if (major < 3 || (major === 3 && minor < 35)) {
2424
+ lock.release();
2425
+ lockDb.close();
2426
+ throw new Error(`SQLite version ${versionRow.v} is too old. Requires >= 3.35.0`);
2427
+ }
2428
+ lock.startHeartbeat();
538
2429
  const store = createConvoyStore(dbPath);
539
- const events = createEventEmitter(store, options.logsDir);
2430
+ const ndjsonPath = options.logsDir
2431
+ ? join(options.logsDir, 'convoy-events.ndjson')
2432
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson');
2433
+ mkdirSync(dirname(ndjsonPath), { recursive: true });
2434
+ const events = createEventEmitter(store, { ndjsonPath });
540
2435
  const wtManager = options._worktreeManager ?? createWorktreeManager(basePath);
541
2436
  const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath);
542
2437
  let result;
@@ -570,18 +2465,199 @@ export function createConvoyEngine(options) {
570
2465
  }
571
2466
  // Remove all orphaned worktrees from the crashed run
572
2467
  await wtManager.removeAll();
2468
+ // NDJSON recovery: truncate partial lines, replay missing events
2469
+ recoverNdjson(store, convoyId, ndjsonPath);
573
2470
  events.emit('convoy_resumed', { original_created_at: convoy.created_at }, { convoy_id: convoyId });
574
- result = await runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime);
2471
+ result = await runConvoy(convoyId, spec, adapter, store, events, wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath, options._reviewRunner);
575
2472
  }
576
2473
  finally {
577
2474
  try {
578
2475
  await exportConvoyToNdjson(store, convoyId, options.logsDir);
579
2476
  }
580
2477
  catch { /* silent */ }
2478
+ events.close();
581
2479
  store.close();
2480
+ lock.release();
2481
+ lockDb.close();
582
2482
  }
583
2483
  return result;
584
2484
  }
585
- return { run, resume };
2485
+ async function retryFailed(convoyId, taskIds) {
2486
+ mkdirSync(dirname(dbPath), { recursive: true });
2487
+ const store = createConvoyStore(dbPath);
2488
+ const ndjsonPath = options.logsDir
2489
+ ? join(options.logsDir, 'convoy-events.ndjson')
2490
+ : join(basePath, '.opencastle', 'logs', 'convoy-events.ndjson');
2491
+ mkdirSync(dirname(ndjsonPath), { recursive: true });
2492
+ const events = createEventEmitter(store, { ndjsonPath });
2493
+ try {
2494
+ const allTasks = store.getTasksByConvoy(convoyId);
2495
+ const retryableStatuses = ['failed', 'gate-failed', 'timed-out', 'review-blocked', 'disputed'];
2496
+ const tasksToRetry = allTasks.filter(t => {
2497
+ if (!retryableStatuses.includes(t.status))
2498
+ return false;
2499
+ if (taskIds && taskIds.length > 0)
2500
+ return taskIds.includes(t.id);
2501
+ return true;
2502
+ });
2503
+ for (const task of tasksToRetry) {
2504
+ store.updateTaskStatus(task.id, convoyId, 'pending', {
2505
+ worker_id: null,
2506
+ worktree: null,
2507
+ started_at: null,
2508
+ finished_at: null,
2509
+ });
2510
+ events.emit('task_retried', { previous_status: task.status }, { convoy_id: convoyId, task_id: task.id });
2511
+ }
2512
+ // Reset convoy status to running so resume can pick it up
2513
+ store.updateConvoyStatus(convoyId, 'running', {});
2514
+ }
2515
+ finally {
2516
+ events.close();
2517
+ store.close();
2518
+ }
2519
+ }
2520
+ function injectTask(convoyId, task) {
2521
+ mkdirSync(dirname(dbPath), { recursive: true });
2522
+ const store = createConvoyStore(dbPath);
2523
+ try {
2524
+ // Idempotency check
2525
+ if (task.idempotency_key) {
2526
+ const existing = store.getTaskByIdempotencyKey(convoyId, task.idempotency_key);
2527
+ if (existing)
2528
+ return existing;
2529
+ }
2530
+ const allTasks = store.getTasksByConvoy(convoyId);
2531
+ // Check max injectable tasks (10)
2532
+ const injectedCount = allTasks.filter(t => t.injected === 1).length;
2533
+ if (injectedCount >= 10) {
2534
+ throw new Error(`Max injectable tasks (10) reached for convoy ${convoyId}`);
2535
+ }
2536
+ // Validate ID uniqueness
2537
+ if (allTasks.some(t => t.id === task.id)) {
2538
+ throw new Error(`Task ID "${task.id}" already exists in convoy ${convoyId}`);
2539
+ }
2540
+ // Validate depends_on references exist
2541
+ const deps = task.depends_on ?? [];
2542
+ for (const dep of deps) {
2543
+ if (!allTasks.some(t => t.id === dep)) {
2544
+ throw new Error(`Dependency "${dep}" not found in convoy ${convoyId}`);
2545
+ }
2546
+ }
2547
+ // Validate no file partition overlap with pending/running tasks
2548
+ const taskFiles = task.files ?? [];
2549
+ if (taskFiles.length > 0) {
2550
+ // Normalize injected task file paths
2551
+ const normalizedTaskFiles = taskFiles.map(normalizePath);
2552
+ // Symlink pre-scan on injected files
2553
+ const basePath = options.basePath ?? process.cwd();
2554
+ try {
2555
+ scanSymlinks(normalizedTaskFiles, basePath);
2556
+ }
2557
+ catch (err) {
2558
+ throw new Error(`Injected task "${task.id}" failed symlink check: ${err.message}`);
2559
+ }
2560
+ // Full partition validation against active tasks
2561
+ const activeTasks = allTasks.filter(t => t.status === 'pending' || t.status === 'running' || t.status === 'assigned');
2562
+ for (const other of activeTasks) {
2563
+ const otherFiles = other.files ? JSON.parse(other.files) : [];
2564
+ if (otherFiles.length === 0)
2565
+ continue;
2566
+ const normalizedOther = otherFiles.map(normalizePath);
2567
+ const overlapping = [];
2568
+ for (const fileA of normalizedTaskFiles) {
2569
+ for (const fileB of normalizedOther) {
2570
+ if (pathsOverlap(fileA, fileB) && !overlapping.includes(fileA)) {
2571
+ overlapping.push(fileA);
2572
+ }
2573
+ }
2574
+ }
2575
+ if (overlapping.length > 0) {
2576
+ throw new Error(`File partition overlap with task "${other.id}": ${overlapping.join(', ')}`);
2577
+ }
2578
+ }
2579
+ }
2580
+ // Detect dependency cycles
2581
+ const depGraph = new Map();
2582
+ for (const t of allTasks) {
2583
+ depGraph.set(t.id, t.depends_on ? JSON.parse(t.depends_on) : []);
2584
+ }
2585
+ depGraph.set(task.id, deps);
2586
+ function hasCycle(nodeId, visited, stack) {
2587
+ visited.add(nodeId);
2588
+ stack.add(nodeId);
2589
+ for (const dep of depGraph.get(nodeId) ?? []) {
2590
+ if (!visited.has(dep)) {
2591
+ if (hasCycle(dep, visited, stack))
2592
+ return true;
2593
+ }
2594
+ else if (stack.has(dep)) {
2595
+ return true;
2596
+ }
2597
+ }
2598
+ stack.delete(nodeId);
2599
+ return false;
2600
+ }
2601
+ const visited = new Set();
2602
+ const stack = new Set();
2603
+ for (const nodeId of depGraph.keys()) {
2604
+ if (!visited.has(nodeId)) {
2605
+ if (hasCycle(nodeId, visited, stack)) {
2606
+ throw new Error(`Dependency cycle detected when injecting task "${task.id}"`);
2607
+ }
2608
+ }
2609
+ }
2610
+ // Insert the task
2611
+ const record = {
2612
+ id: task.id,
2613
+ convoy_id: convoyId,
2614
+ phase: task.phase,
2615
+ prompt: task.prompt,
2616
+ agent: task.agent,
2617
+ adapter: null,
2618
+ model: null,
2619
+ timeout_ms: task.timeout_ms ?? 1_800_000,
2620
+ status: 'pending',
2621
+ worker_id: null,
2622
+ worktree: null,
2623
+ output: null,
2624
+ exit_code: null,
2625
+ started_at: null,
2626
+ finished_at: null,
2627
+ retries: 0,
2628
+ max_retries: task.max_retries ?? 1,
2629
+ files: taskFiles.length > 0 ? JSON.stringify(taskFiles) : null,
2630
+ depends_on: deps.length > 0 ? JSON.stringify(deps) : null,
2631
+ prompt_tokens: null,
2632
+ completion_tokens: null,
2633
+ total_tokens: null,
2634
+ cost_usd: null,
2635
+ gates: null,
2636
+ on_exhausted: task.on_exhausted ?? 'dlq',
2637
+ injected: 1,
2638
+ provenance: task.provenance ?? null,
2639
+ idempotency_key: task.idempotency_key ?? null,
2640
+ current_step: null,
2641
+ total_steps: null,
2642
+ review_level: null,
2643
+ review_verdict: null,
2644
+ review_tokens: null,
2645
+ review_model: null,
2646
+ panel_attempts: 0,
2647
+ dispute_id: null,
2648
+ drift_score: null,
2649
+ drift_retried: 0,
2650
+ outputs: null,
2651
+ inputs: null,
2652
+ discovered_issues: null,
2653
+ };
2654
+ store.insertInjectedTask(record);
2655
+ return record;
2656
+ }
2657
+ finally {
2658
+ store.close();
2659
+ }
2660
+ }
2661
+ return { run, resume, retryFailed, injectTask };
586
2662
  }
587
2663
  //# sourceMappingURL=engine.js.map