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