principles-disciple 1.8.2 → 1.8.3
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/openclaw.plugin.json +4 -4
- package/package.json +1 -1
- package/templates/langs/en/skills/ai-sprint-orchestration/EXAMPLES.md +63 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/REFERENCE.md +136 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/SKILL.md +67 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/agent-registry.json +214 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +107 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +107 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +105 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +108 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/workflow-v1-acceptance-checklist.md +58 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/references/workflow-v1.4-work-unit-handoff.md +190 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/runtime/.gitignore +2 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/archive.mjs +310 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/contract-enforcement.mjs +683 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/decision.mjs +604 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/state-store.mjs +32 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +707 -0
- package/templates/langs/en/skills/ai-sprint-orchestration/scripts/run.mjs +3419 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/EXAMPLES.md +63 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/REFERENCE.md +136 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/SKILL.md +67 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/agent-registry.json +214 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/bugfix-complex-template.json +107 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/feature-complex-template.json +107 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal-verify.json +105 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/specs/workflow-validation-minimal.json +108 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/workflow-v1-acceptance-checklist.md +58 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/references/workflow-v1.4-work-unit-handoff.md +190 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/runtime/.gitignore +2 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/archive.mjs +310 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/contract-enforcement.mjs +683 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/decision.mjs +604 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/state-store.mjs +32 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/lib/task-specs.mjs +707 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs +3419 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/test/archive.test.mjs +230 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/test/contract-enforcement.test.mjs +672 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/test/decision.test.mjs +1321 -0
- package/templates/langs/zh/skills/ai-sprint-orchestration/test/run.test.mjs +1419 -0
|
@@ -0,0 +1,3419 @@
|
|
|
1
|
+
// Packaged AI sprint orchestrator entrypoint.
|
|
2
|
+
// Canonical repo copy lives at packages/openclaw-plugin/templates/langs/zh/skills/ai-sprint-orchestration/scripts/run.mjs.
|
|
3
|
+
// Keep this package-local copy in sync only for package CLI, validation, and runtime-layout changes.
|
|
4
|
+
|
|
5
|
+
import { spawnSync, spawn } from 'child_process';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { decideStage, buildStageMetrics, buildHandoff } from './lib/decision.mjs';
|
|
12
|
+
import { ensureDir, appendText, fileExists, readJson, writeJson, writeText } from './lib/state-store.mjs';
|
|
13
|
+
import { buildRolePrompt, buildStageBrief, getTaskSpec, getActiveWorkUnit } from './lib/task-specs.mjs';
|
|
14
|
+
import { archiveRunById } from './lib/archive.mjs';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
18
|
+
const referencesRoot = path.join(packageRoot, 'references');
|
|
19
|
+
const defaultRuntimeRoot = path.join(packageRoot, 'runtime');
|
|
20
|
+
let runtimeRoot = process.env.AI_SPRINT_RUNTIME_ROOT
|
|
21
|
+
? path.resolve(process.env.AI_SPRINT_RUNTIME_ROOT)
|
|
22
|
+
: defaultRuntimeRoot;
|
|
23
|
+
let sprintRoot = path.join(runtimeRoot, 'runs');
|
|
24
|
+
let tempRoot = path.join(runtimeRoot, 'tmp');
|
|
25
|
+
// Resolve acpx binary path and node executable — spawn directly via node to avoid
|
|
26
|
+
// shebang/env resolution issues when cron/nohup has a minimal PATH.
|
|
27
|
+
const acpxBin = (() => {
|
|
28
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
29
|
+
const r = spawnSync(cmd, ['acpx'], { encoding: 'utf8' });
|
|
30
|
+
if (r.status === 0) {
|
|
31
|
+
const lines = r.stdout.trim().split(/\r?\n/);
|
|
32
|
+
const symlink = lines[0].trim();
|
|
33
|
+
try {
|
|
34
|
+
return fs.realpathSync(symlink);
|
|
35
|
+
} catch {
|
|
36
|
+
return symlink;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return 'acpx';
|
|
40
|
+
})();
|
|
41
|
+
const nodeBin = process.execPath; // e.g. /usr/bin/node — reliable, no PATH search needed
|
|
42
|
+
// Extended env for detached child processes.
|
|
43
|
+
// Linux: includes /usr/bin so the Node.js shebang (#!/usr/bin/env node) resolves
|
|
44
|
+
// correctly even when cron/nohup has a minimal PATH.
|
|
45
|
+
// Windows: inherit PATH as-is — nodeBin is absolute, no PATH search needed.
|
|
46
|
+
const acpxEnv = process.platform === 'win32'
|
|
47
|
+
? { ...process.env }
|
|
48
|
+
: {
|
|
49
|
+
...process.env,
|
|
50
|
+
PATH: `${process.env.PATH ?? ''}:/usr/local/bin:/usr/bin:/bin`,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// On Linux, ignore SIGHUP so SSH disconnect doesn't kill the orchestrator.
|
|
54
|
+
// Skip in test environments to avoid interfering with test signal handling.
|
|
55
|
+
if (process.platform !== 'win32' && !process.env.VITEST && process.env.NODE_ENV !== 'test') {
|
|
56
|
+
process.on('SIGHUP', () => { /* ignored — survive SSH disconnect */ });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseArgs(argv) {
|
|
60
|
+
const args = {};
|
|
61
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
62
|
+
const token = argv[i];
|
|
63
|
+
if (!token.startsWith('--')) continue;
|
|
64
|
+
const key = token.slice(2);
|
|
65
|
+
const next = argv[i + 1];
|
|
66
|
+
if (!next || next.startsWith('--')) {
|
|
67
|
+
args[key] = true;
|
|
68
|
+
} else {
|
|
69
|
+
args[key] = next;
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nowIso() {
|
|
77
|
+
return new Date().toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function configureRuntimeRoots(rootPath) {
|
|
81
|
+
runtimeRoot = path.resolve(rootPath);
|
|
82
|
+
sprintRoot = path.join(runtimeRoot, 'runs');
|
|
83
|
+
tempRoot = path.join(runtimeRoot, 'tmp');
|
|
84
|
+
process.env.AI_SPRINT_RUNTIME_ROOT = runtimeRoot;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkAcpxAvailable() {
|
|
88
|
+
return process.platform === 'win32'
|
|
89
|
+
? spawnSync('powershell.exe', ['-NoProfile', '-Command', 'acpx --version'], {
|
|
90
|
+
encoding: 'utf8',
|
|
91
|
+
shell: false,
|
|
92
|
+
timeout: 10_000,
|
|
93
|
+
})
|
|
94
|
+
: spawnSync(nodeBin, [acpxBin, '--version'], {
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
shell: false,
|
|
97
|
+
timeout: 10_000,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runSelfCheck() {
|
|
102
|
+
ensureDir(runtimeRoot);
|
|
103
|
+
ensureDir(sprintRoot);
|
|
104
|
+
ensureDir(tempRoot);
|
|
105
|
+
|
|
106
|
+
const checks = [];
|
|
107
|
+
const record = (name, ok, details) => {
|
|
108
|
+
checks.push({ name, ok, details });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
record('package_root_exists', fileExists(packageRoot), packageRoot);
|
|
112
|
+
record('references_root_exists', fileExists(referencesRoot), referencesRoot);
|
|
113
|
+
record('runtime_root_exists', fileExists(runtimeRoot), runtimeRoot);
|
|
114
|
+
record('agent_registry_exists', fileExists(path.join(referencesRoot, 'agent-registry.json')), path.join(referencesRoot, 'agent-registry.json'));
|
|
115
|
+
|
|
116
|
+
const builtInSpecs = [
|
|
117
|
+
'workflow-validation-minimal',
|
|
118
|
+
'workflow-validation-minimal-verify',
|
|
119
|
+
];
|
|
120
|
+
for (const specId of builtInSpecs) {
|
|
121
|
+
try {
|
|
122
|
+
const spec = getTaskSpec(specId, null);
|
|
123
|
+
record(`spec:${specId}`, true, spec.title);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
record(`spec:${specId}`, false, err.message);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const acpxCheck = checkAcpxAvailable();
|
|
130
|
+
record('acpx_available', acpxCheck.status === 0, (acpxCheck.stdout || acpxCheck.stderr || '').trim() || `status=${acpxCheck.status}`);
|
|
131
|
+
|
|
132
|
+
const probePath = path.join(runtimeRoot, '.self-check-write-probe.tmp');
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(probePath, 'ok', 'utf8');
|
|
135
|
+
fs.rmSync(probePath, { force: true });
|
|
136
|
+
record('runtime_root_writable', true, runtimeRoot);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
record('runtime_root_writable', false, err.message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const failed = checks.filter((check) => !check.ok);
|
|
142
|
+
const lines = [
|
|
143
|
+
'AI Sprint Orchestrator Self Check',
|
|
144
|
+
'',
|
|
145
|
+
`Package root: ${packageRoot}`,
|
|
146
|
+
`Runtime root: ${runtimeRoot}`,
|
|
147
|
+
'',
|
|
148
|
+
...checks.map((check) => `${check.ok ? '[OK]' : '[FAIL]'} ${check.name}: ${check.details}`),
|
|
149
|
+
];
|
|
150
|
+
console.log(lines.join('\n'));
|
|
151
|
+
if (failed.length > 0) {
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function slugify(value) {
|
|
157
|
+
return String(value)
|
|
158
|
+
.toLowerCase()
|
|
159
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
160
|
+
.replace(/^-+|-+$/g, '')
|
|
161
|
+
.slice(0, 60);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function makeRunId(taskId) {
|
|
165
|
+
const stamp = nowIso().replace(/[:.]/g, '-');
|
|
166
|
+
return `${stamp}-${slugify(taskId)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createSprintState(spec, runId, specPath) {
|
|
170
|
+
return {
|
|
171
|
+
runId,
|
|
172
|
+
taskId: spec.id,
|
|
173
|
+
title: spec.title,
|
|
174
|
+
specPath: specPath || null,
|
|
175
|
+
status: 'running',
|
|
176
|
+
currentStageIndex: 0,
|
|
177
|
+
currentStage: spec.stages[0],
|
|
178
|
+
currentWorkUnitIndex: 0,
|
|
179
|
+
currentRound: 1,
|
|
180
|
+
maxRoundsPerStage: spec.maxRoundsPerStage,
|
|
181
|
+
maxRuntimeMinutes: spec.maxRuntimeMinutes,
|
|
182
|
+
staleAfterMs: spec.staleAfterMs ?? 5 * 60 * 1000,
|
|
183
|
+
orchestratorPid: process.pid,
|
|
184
|
+
lastHeartbeatAt: nowIso(),
|
|
185
|
+
currentRole: null,
|
|
186
|
+
haltReason: null,
|
|
187
|
+
mergePending: null,
|
|
188
|
+
worktree: null, // { worktreePath, branchName, headSha, baseBranch } — set by ensureWorktree
|
|
189
|
+
consecutiveTimeouts: {}, // { stageName: count } — tracks consecutive timeouts per stage
|
|
190
|
+
createdAt: nowIso(),
|
|
191
|
+
updatedAt: nowIso(),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function loadOrInitState(args) {
|
|
196
|
+
ensureDir(sprintRoot);
|
|
197
|
+
|
|
198
|
+
if (args.resume) {
|
|
199
|
+
const runDir = path.join(sprintRoot, args.resume);
|
|
200
|
+
const sprintFile = path.join(runDir, 'sprint.json');
|
|
201
|
+
if (!fileExists(sprintFile)) {
|
|
202
|
+
throw new Error(`Run not found: ${args.resume}`);
|
|
203
|
+
}
|
|
204
|
+
const state = reconcileRunState(runDir, readJson(sprintFile));
|
|
205
|
+
if (state.status === 'halted' || state.status === 'aborted') {
|
|
206
|
+
// Validate: if halted mid-stage, the previous stage must have advanced
|
|
207
|
+
// Special case: implement-pass-1 revise → implement-pass-2 is valid routing
|
|
208
|
+
if (state.currentStageIndex > 0) {
|
|
209
|
+
const prevStageName = loadSpec(state, args).stages[state.currentStageIndex - 1];
|
|
210
|
+
const prevDecisionPath = path.join(runDir, 'stages', `${String(state.currentStageIndex).padStart(2, '0')}-${prevStageName}`, 'decision.md');
|
|
211
|
+
if (fileExists(prevDecisionPath)) {
|
|
212
|
+
const prevDecisionText = fs.readFileSync(prevDecisionPath, 'utf8');
|
|
213
|
+
const outcomeMatch = prevDecisionText.match(/Outcome:\s*(\w+)/);
|
|
214
|
+
if (outcomeMatch && outcomeMatch[1] !== 'advance') {
|
|
215
|
+
// Check for special routing: implement-pass-1 revise → implement-pass-2
|
|
216
|
+
const isSpecialRoute = state.currentStage === 'implement-pass-2' && prevStageName === 'implement-pass-1' && outcomeMatch[1] === 'revise';
|
|
217
|
+
if (!isSpecialRoute) {
|
|
218
|
+
throw new Error(`Cannot resume: previous stage "${prevStageName}" outcome was "${outcomeMatch[1]}" (expected "advance"). Fix the previous stage first.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const previousStatus = state.status;
|
|
224
|
+
state.status = 'running';
|
|
225
|
+
state.haltReason = null;
|
|
226
|
+
state.orchestratorPid = process.pid;
|
|
227
|
+
state.lastHeartbeatAt = nowIso();
|
|
228
|
+
state.updatedAt = nowIso();
|
|
229
|
+
// Refresh maxRoundsPerStage from spec in case spec was updated since the sprint started
|
|
230
|
+
const spec = loadSpec(state, args);
|
|
231
|
+
if (spec.maxRoundsPerStage !== undefined && spec.maxRoundsPerStage !== state.maxRoundsPerStage) {
|
|
232
|
+
appendTimeline(runDir, `maxRoundsPerStage updated from spec: ${state.maxRoundsPerStage} → ${spec.maxRoundsPerStage}`);
|
|
233
|
+
state.maxRoundsPerStage = spec.maxRoundsPerStage;
|
|
234
|
+
}
|
|
235
|
+
writeJson(sprintFile, state);
|
|
236
|
+
appendTimeline(runDir, `Sprint resumed from ${previousStatus} by operator`);
|
|
237
|
+
}
|
|
238
|
+
return { runDir, state, resumed: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!args.task) {
|
|
242
|
+
throw new Error('Missing required --task <task-id>');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const spec = getTaskSpec(args.task, args.taskSpec);
|
|
246
|
+
const runId = makeRunId(spec.id);
|
|
247
|
+
const runDir = path.join(sprintRoot, runId);
|
|
248
|
+
const state = createSprintState(spec, runId, args.taskSpec);
|
|
249
|
+
ensureDir(runDir);
|
|
250
|
+
writeJson(path.join(runDir, 'sprint.json'), state);
|
|
251
|
+
writeText(path.join(runDir, 'timeline.md'), `# Timeline\n\n- ${nowIso()} Created sprint ${runId}\n`);
|
|
252
|
+
writeText(path.join(runDir, 'latest-summary.md'), `# Latest Summary\n\n- Status: ${state.status}\n- Stage: ${state.currentStage}\n- Round: ${state.currentRound}\n`);
|
|
253
|
+
return { runDir, state, resumed: false };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function saveState(runDir, state) {
|
|
257
|
+
state.updatedAt = nowIso();
|
|
258
|
+
writeJson(path.join(runDir, 'sprint.json'), state);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function heartbeatState(runDir, state, patch = {}) {
|
|
262
|
+
Object.assign(state, patch, {
|
|
263
|
+
orchestratorPid: process.pid,
|
|
264
|
+
lastHeartbeatAt: nowIso(),
|
|
265
|
+
});
|
|
266
|
+
saveState(runDir, state);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function updateSummary(runDir, lines) {
|
|
270
|
+
writeText(path.join(runDir, 'latest-summary.md'), `# Latest Summary\n\n${lines.map((line) => `- ${line}`).join('\n')}\n`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function inferFailureClassification({ summary = '', blockers = [], reviewerTimeouts = null, reviewerViolations = null }) {
|
|
274
|
+
const combined = [summary, ...(blockers ?? [])].join(' ').toLowerCase();
|
|
275
|
+
|
|
276
|
+
if (/acpx|path|enoent|eacces|eprem|permission|writable|runtime root|command not found|not available/.test(combined)) {
|
|
277
|
+
return {
|
|
278
|
+
failureClassification: 'environment issue',
|
|
279
|
+
failureSource: 'runtime environment',
|
|
280
|
+
recommendedNextAction: 'Repair the environment or required binaries, then rerun self-check and validation.',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (/sample-spec|product-side|sample-side|openclaw-plugin|d:\\code\\openclaw|integrationphase|branchworkspace/.test(combined)) {
|
|
285
|
+
return {
|
|
286
|
+
failureClassification: 'sample-spec issue',
|
|
287
|
+
failureSource: 'sample/spec contract',
|
|
288
|
+
recommendedNextAction: 'Classify and stop. Do not reopen product-side closure work in this workflow milestone.',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if ((reviewerTimeouts?.length ?? 0) > 0 || (reviewerViolations?.length ?? 0) > 0 || /missing reports|schema violation|report invalidated|timed out|agent .*failed|verdict|dimensions/.test(combined)) {
|
|
293
|
+
return {
|
|
294
|
+
failureClassification: 'agent behavior issue',
|
|
295
|
+
failureSource: 'role execution or report quality',
|
|
296
|
+
recommendedNextAction: 'Adjust agent profile, fallback, or prompt discipline; do not treat this as a product-side bug.',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
failureClassification: 'workflow bug',
|
|
302
|
+
failureSource: 'orchestrator runtime',
|
|
303
|
+
recommendedNextAction: 'Fix the workflow plumbing, persistence, or artifact layout before retrying.',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function updateSummaryWithClassification(runDir, lines, classification = null) {
|
|
308
|
+
const enriched = [...lines];
|
|
309
|
+
if (classification?.failureClassification) {
|
|
310
|
+
enriched.push(`Failure classification: ${classification.failureClassification}`);
|
|
311
|
+
enriched.push(`Failure source: ${classification.failureSource}`);
|
|
312
|
+
enriched.push(`Recommended next action: ${classification.recommendedNextAction}`);
|
|
313
|
+
}
|
|
314
|
+
updateSummary(runDir, enriched);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function readStageFailureClassification(runDir, state) {
|
|
318
|
+
try {
|
|
319
|
+
const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
|
|
320
|
+
const scorecardPath = path.join(runDir, 'stages', stageDirName, 'scorecard.json');
|
|
321
|
+
if (!fileExists(scorecardPath)) return null;
|
|
322
|
+
const scorecard = readJson(scorecardPath);
|
|
323
|
+
if (!scorecard?.failureClassification) return null;
|
|
324
|
+
return {
|
|
325
|
+
failureClassification: scorecard.failureClassification,
|
|
326
|
+
failureSource: scorecard.failureSource ?? 'n/a',
|
|
327
|
+
recommendedNextAction: scorecard.recommendedNextAction ?? 'n/a',
|
|
328
|
+
};
|
|
329
|
+
} catch {
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function extractEvidenceFiles(reportText) {
|
|
335
|
+
if (!reportText) return [];
|
|
336
|
+
const matches = [
|
|
337
|
+
...reportText.matchAll(/files_(?:checked|verified):\s*([^\n]+)/gi),
|
|
338
|
+
];
|
|
339
|
+
return matches
|
|
340
|
+
.flatMap((match) => match[1].split(','))
|
|
341
|
+
.map((value) => value.trim())
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function writeCheckpointSummary(stageDir, { decision, handoff = null, producer = '', reviewerA = '', reviewerB = '', globalReviewer = '' }) {
|
|
346
|
+
const verifiedFiles = Array.from(new Set([
|
|
347
|
+
...extractEvidenceFiles(producer),
|
|
348
|
+
...extractEvidenceFiles(reviewerA),
|
|
349
|
+
...extractEvidenceFiles(reviewerB),
|
|
350
|
+
...extractEvidenceFiles(globalReviewer),
|
|
351
|
+
]));
|
|
352
|
+
const accomplished = handoff?.contractItems?.filter((item) => item.status === 'DONE').map((item) => item.deliverable) ?? [];
|
|
353
|
+
const blockers = decision.blockers?.length ? decision.blockers : ['None.'];
|
|
354
|
+
const nextFocus = handoff?.focusForNextRound
|
|
355
|
+
?? decision.nextRunRecommendation?.reasons?.[0]
|
|
356
|
+
?? decision.summary;
|
|
357
|
+
const lines = [
|
|
358
|
+
'## Accomplished',
|
|
359
|
+
...(accomplished.length ? accomplished.map((item) => `- ${item}`) : ['- No contract deliverables were marked DONE in this round.']),
|
|
360
|
+
'',
|
|
361
|
+
'## Blockers',
|
|
362
|
+
...blockers.map((item) => `- ${item}`),
|
|
363
|
+
'',
|
|
364
|
+
'## Next Focus',
|
|
365
|
+
nextFocus,
|
|
366
|
+
'',
|
|
367
|
+
'## Verified Files',
|
|
368
|
+
...(verifiedFiles.length ? verifiedFiles.map((item) => `- ${item}`) : ['- None recorded.']),
|
|
369
|
+
];
|
|
370
|
+
writeText(path.join(stageDir, 'checkpoint-summary.md'), `${lines.join('\n')}\n`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function writeStageFailureArtifacts({ runDir, state, stageName, stageDir, decisionPath, scorecardPath, summary, blockers = [] }) {
|
|
374
|
+
const failure = inferFailureClassification({ summary, blockers });
|
|
375
|
+
writeText(decisionPath, [
|
|
376
|
+
'# Decision',
|
|
377
|
+
'',
|
|
378
|
+
`- Stage: ${stageName}`,
|
|
379
|
+
`- Round: ${state.currentRound}`,
|
|
380
|
+
'- Outcome: error',
|
|
381
|
+
'',
|
|
382
|
+
'## Summary',
|
|
383
|
+
summary,
|
|
384
|
+
'',
|
|
385
|
+
'## Blockers',
|
|
386
|
+
...(blockers.length ? blockers.map((item) => `- ${item}`) : ['- None.']),
|
|
387
|
+
'',
|
|
388
|
+
'## Failure Classification',
|
|
389
|
+
`- Type: ${failure.failureClassification}`,
|
|
390
|
+
`- Source: ${failure.failureSource}`,
|
|
391
|
+
`- Recommended Next Action: ${failure.recommendedNextAction}`,
|
|
392
|
+
'',
|
|
393
|
+
].join('\n'));
|
|
394
|
+
writeJson(scorecardPath, {
|
|
395
|
+
stage: stageName,
|
|
396
|
+
round: state.currentRound,
|
|
397
|
+
outcome: 'error',
|
|
398
|
+
summary,
|
|
399
|
+
blockers,
|
|
400
|
+
failureClassification: failure.failureClassification,
|
|
401
|
+
failureSource: failure.failureSource,
|
|
402
|
+
recommendedNextAction: failure.recommendedNextAction,
|
|
403
|
+
updatedAt: nowIso(),
|
|
404
|
+
});
|
|
405
|
+
updateSummaryWithClassification(runDir, [
|
|
406
|
+
`Status: ${state.status}`,
|
|
407
|
+
`Stage: ${stageName}`,
|
|
408
|
+
`Round: ${state.currentRound}`,
|
|
409
|
+
`Halt reason: ${summary}`,
|
|
410
|
+
], failure);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const MAX_TIMELINE_LINES = 500;
|
|
414
|
+
|
|
415
|
+
function appendTimeline(runDir, line) {
|
|
416
|
+
const tlPath = path.join(runDir, 'timeline.md');
|
|
417
|
+
appendText(tlPath, `- ${nowIso()} ${line}\n`);
|
|
418
|
+
|
|
419
|
+
// Rotate if too long
|
|
420
|
+
if (fileExists(tlPath)) {
|
|
421
|
+
const content = fs.readFileSync(tlPath, 'utf8');
|
|
422
|
+
const lineCount = content.split('\n').filter((l) => l.startsWith('- ')).length;
|
|
423
|
+
if (lineCount > MAX_TIMELINE_LINES) {
|
|
424
|
+
const archivePath = path.join(runDir, 'timeline-archive.md');
|
|
425
|
+
appendText(archivePath, content);
|
|
426
|
+
const lines = content.split('\n').filter((l) => l.startsWith('- '));
|
|
427
|
+
const kept = lines.slice(-300);
|
|
428
|
+
writeText(tlPath, `# Timeline (archived ${lineCount - 300} older entries)\n\n${kept.join('\n')}\n`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function runAgent({ cwd, agent, model, prompt, timeoutSeconds = 1800, failLogPath = null, promptDir = cwd, runDir = null, roleLabel = agent }) {
|
|
434
|
+
// promptDir (default=cwd) is where we write the prompt file; must exist and be writable.
|
|
435
|
+
// On Windows, avoid os.tmpdir() (short ~ paths) and non-existent branchWorkspace dirs.
|
|
436
|
+
const promptFile = path.join(promptDir, `.ai-sprint-prompt-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`);
|
|
437
|
+
fs.writeFileSync(promptFile, prompt, 'utf8');
|
|
438
|
+
|
|
439
|
+
let result;
|
|
440
|
+
try {
|
|
441
|
+
if (process.platform === 'win32') {
|
|
442
|
+
result = spawnSync(
|
|
443
|
+
'powershell.exe',
|
|
444
|
+
[
|
|
445
|
+
'-NoProfile',
|
|
446
|
+
'-Command',
|
|
447
|
+
`acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
|
|
448
|
+
],
|
|
449
|
+
{
|
|
450
|
+
cwd,
|
|
451
|
+
encoding: 'utf8',
|
|
452
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
453
|
+
timeout: (timeoutSeconds + 60) * 1000,
|
|
454
|
+
shell: false,
|
|
455
|
+
env: {
|
|
456
|
+
...process.env,
|
|
457
|
+
AI_SPRINT_CWD: cwd,
|
|
458
|
+
AI_SPRINT_MODEL: model,
|
|
459
|
+
AI_SPRINT_TIMEOUT: String(timeoutSeconds),
|
|
460
|
+
AI_SPRINT_PROMPT: promptFile,
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
} else {
|
|
465
|
+
result = spawnSync(
|
|
466
|
+
nodeBin,
|
|
467
|
+
[acpxBin, '--cwd', cwd, '--approve-all', '--model', model, '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile],
|
|
468
|
+
{
|
|
469
|
+
cwd,
|
|
470
|
+
encoding: 'utf8',
|
|
471
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
472
|
+
timeout: (timeoutSeconds + 60) * 1000,
|
|
473
|
+
shell: false,
|
|
474
|
+
// Detach child process into its own session group so SIGHUP
|
|
475
|
+
// from SSH disconnect doesn't propagate to agent processes.
|
|
476
|
+
detached: true,
|
|
477
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
478
|
+
env: acpxEnv,
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
// Fallback if resolved path is stale (e.g., npm package updated between module load and spawn)
|
|
482
|
+
if (result.error && result.error.code === 'ENOENT') {
|
|
483
|
+
result = spawnSync(
|
|
484
|
+
nodeBin,
|
|
485
|
+
[acpxBin, '--cwd', cwd, '--approve-all', '--model', model, '--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile],
|
|
486
|
+
{
|
|
487
|
+
cwd,
|
|
488
|
+
encoding: 'utf8',
|
|
489
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
490
|
+
timeout: (timeoutSeconds + 60) * 1000,
|
|
491
|
+
shell: false,
|
|
492
|
+
detached: true,
|
|
493
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
494
|
+
env: acpxEnv,
|
|
495
|
+
},
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
} finally {
|
|
500
|
+
fs.rmSync(promptFile, { force: true });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const stdout = result.stdout ?? '';
|
|
504
|
+
const stderr = result.stderr ?? '';
|
|
505
|
+
|
|
506
|
+
if (result.error || result.status !== 0) {
|
|
507
|
+
// Map acpx exit codes to semantic error types for better error classification:
|
|
508
|
+
// 0 = success
|
|
509
|
+
// 1 = runtime error (agent crash, ACP failure)
|
|
510
|
+
// 2 = usage error (bad flags, missing prompt)
|
|
511
|
+
// 3 = timeout (acpx --timeout exceeded)
|
|
512
|
+
// 4 = no session (session lost/closed)
|
|
513
|
+
// 5 = permission denied (agent approval rejected)
|
|
514
|
+
// 130 = interrupted (SIGINT/SIGHUP — SSH disconnect)
|
|
515
|
+
const exitCode = result.status ?? 1;
|
|
516
|
+
const errorType = exitCode === 3 ? 'timeout'
|
|
517
|
+
: exitCode === 4 ? 'no_session'
|
|
518
|
+
: exitCode === 5 ? 'permission_denied'
|
|
519
|
+
: exitCode === 130 ? 'interrupted'
|
|
520
|
+
: exitCode === 2 ? 'usage_error'
|
|
521
|
+
: 'runtime_error';
|
|
522
|
+
|
|
523
|
+
if ((result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') && result.pid) {
|
|
524
|
+
terminateProcessTree(result.pid, { runDir, label: roleLabel });
|
|
525
|
+
}
|
|
526
|
+
if (failLogPath) {
|
|
527
|
+
ensureDir(path.dirname(failLogPath));
|
|
528
|
+
writeText(failLogPath, `# Agent Failure Log\n\n- agent: ${agent}\n- model: ${model}\n- exitStatus: ${result.status ?? 'N/A'}\n- exitCode: ${exitCode}\n- errorType: ${errorType}\n- error: ${result.error?.message ?? 'none'}\n\n## stdout\n\n${stdout}\n\n## stderr\n\n${stderr}\n`);
|
|
529
|
+
}
|
|
530
|
+
if (result.error) throw result.error;
|
|
531
|
+
throw Object.assign(new Error(`Agent ${agent} failed with status ${result.status} (${errorType})\n${stdout.slice(0, 2000)}\n${stderr.slice(0, 2000)}`), {
|
|
532
|
+
acpxExitCode: exitCode,
|
|
533
|
+
acpxErrorType: errorType,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return stdout.trim();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Async agent runner using spawn (non-blocking) — used for parallel reviewer execution.
|
|
542
|
+
* Returns a Promise that resolves with { stdout, stderr, status } or rejects on error.
|
|
543
|
+
*/
|
|
544
|
+
function runAgentAsync({ cwd, agent, model, prompt, timeoutSeconds = 1800, promptDir = cwd, runDir = null, roleLabel = agent, onSpawn = null }) {
|
|
545
|
+
return new Promise((resolve, reject) => {
|
|
546
|
+
const promptFile = path.join(promptDir, `.ai-sprint-prompt-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
547
|
+
|
|
548
|
+
const cleanup = () => {
|
|
549
|
+
try { fs.rmSync(promptFile, { force: true }); } catch {}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
fs.writeFileSync(promptFile, prompt, 'utf8');
|
|
554
|
+
} catch (writeErr) {
|
|
555
|
+
reject(new Error(`Failed to write prompt file: ${writeErr.message}`));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const timeoutMs = (timeoutSeconds + 60) * 1000;
|
|
560
|
+
let settled = false;
|
|
561
|
+
let stdout = '';
|
|
562
|
+
let stderr = '';
|
|
563
|
+
let proc;
|
|
564
|
+
|
|
565
|
+
const timer = setTimeout(() => {
|
|
566
|
+
if (!settled) {
|
|
567
|
+
settled = true;
|
|
568
|
+
clearTimeout(timer);
|
|
569
|
+
cleanup();
|
|
570
|
+
terminateProcessTree(proc?.pid, { runDir, label: roleLabel });
|
|
571
|
+
reject(new Error(`Agent ${agent} timed out after ${timeoutSeconds}s`));
|
|
572
|
+
}
|
|
573
|
+
}, timeoutMs);
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
if (process.platform === 'win32') {
|
|
577
|
+
proc = spawn('powershell.exe', [
|
|
578
|
+
'-NoProfile', '-Command',
|
|
579
|
+
`acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
|
|
580
|
+
], {
|
|
581
|
+
cwd,
|
|
582
|
+
encoding: 'utf8',
|
|
583
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
584
|
+
shell: false,
|
|
585
|
+
env: {
|
|
586
|
+
...process.env,
|
|
587
|
+
AI_SPRINT_CWD: cwd,
|
|
588
|
+
AI_SPRINT_MODEL: model,
|
|
589
|
+
AI_SPRINT_TIMEOUT: String(timeoutSeconds),
|
|
590
|
+
AI_SPRINT_PROMPT: promptFile,
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
} else {
|
|
594
|
+
try {
|
|
595
|
+
proc = spawn(nodeBin, [acpxBin,
|
|
596
|
+
'--cwd', cwd, '--approve-all', '--model', model,
|
|
597
|
+
'--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
|
|
598
|
+
], {
|
|
599
|
+
cwd,
|
|
600
|
+
encoding: 'utf8',
|
|
601
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
602
|
+
shell: false,
|
|
603
|
+
detached: true,
|
|
604
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
605
|
+
env: acpxEnv,
|
|
606
|
+
});
|
|
607
|
+
} catch (enoentErr) {
|
|
608
|
+
// Fallback if resolved path is stale
|
|
609
|
+
if (enoentErr.code === 'ENOENT') {
|
|
610
|
+
proc = spawn(nodeBin, [acpxBin,
|
|
611
|
+
'--cwd', cwd, '--approve-all', '--model', model,
|
|
612
|
+
'--timeout', String(timeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
|
|
613
|
+
], {
|
|
614
|
+
cwd,
|
|
615
|
+
encoding: 'utf8',
|
|
616
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
617
|
+
shell: false,
|
|
618
|
+
detached: true,
|
|
619
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
620
|
+
env: acpxEnv,
|
|
621
|
+
});
|
|
622
|
+
} else {
|
|
623
|
+
throw enoentErr;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (proc?.pid && onSpawn) onSpawn(proc.pid);
|
|
628
|
+
} catch (spawnErr) {
|
|
629
|
+
settled = true;
|
|
630
|
+
clearTimeout(timer);
|
|
631
|
+
cleanup();
|
|
632
|
+
reject(new Error(`Failed to spawn agent ${agent}: ${spawnErr.message}`));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
proc.stdout.on('data', (data) => { stdout += data; });
|
|
637
|
+
proc.stderr.on('data', (data) => { stderr += data; });
|
|
638
|
+
// Use 'exit' instead of 'close' — on Windows, powershell.exe child process chains
|
|
639
|
+
// may keep stdio pipes open after exit, causing 'close' to never fire.
|
|
640
|
+
// 'exit' fires when the process exits regardless of stdio pipe state.
|
|
641
|
+
proc.on('exit', (code) => {
|
|
642
|
+
if (!settled) {
|
|
643
|
+
settled = true;
|
|
644
|
+
clearTimeout(timer);
|
|
645
|
+
// Read any remaining buffered stdout/stderr before resolving
|
|
646
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), status: code });
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
// Cleanup prompt file on close (stdio fully drained) — separate from resolve
|
|
650
|
+
proc.on('close', () => {
|
|
651
|
+
cleanup();
|
|
652
|
+
});
|
|
653
|
+
proc.on('error', (err) => {
|
|
654
|
+
if (!settled) {
|
|
655
|
+
settled = true;
|
|
656
|
+
clearTimeout(timer);
|
|
657
|
+
cleanup();
|
|
658
|
+
reject(err);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Check if there's progress evidence in the last N seconds.
|
|
666
|
+
* Progress signals:
|
|
667
|
+
* 1. Worklog file updated recently
|
|
668
|
+
* 2. Target directory has file modifications
|
|
669
|
+
* 3. Stdout has new content (tracked externally)
|
|
670
|
+
*/
|
|
671
|
+
function checkProgressEvidence({ worktreePath, worklogPath, lastStdoutLength, currentStdout, lookbackSeconds = 120 }) {
|
|
672
|
+
const now = Date.now();
|
|
673
|
+
const lookbackMs = lookbackSeconds * 1000;
|
|
674
|
+
const signals = [];
|
|
675
|
+
|
|
676
|
+
// Check worklog file mtime
|
|
677
|
+
if (worklogPath && fileExists(worklogPath)) {
|
|
678
|
+
try {
|
|
679
|
+
const stat = fs.statSync(worklogPath);
|
|
680
|
+
if (now - stat.mtimeMs < lookbackMs) {
|
|
681
|
+
signals.push({ type: 'worklog_updated', age: Math.round((now - stat.mtimeMs) / 1000) });
|
|
682
|
+
}
|
|
683
|
+
} catch {}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Check for new stdout content
|
|
687
|
+
if (lastStdoutLength !== undefined && currentStdout !== undefined) {
|
|
688
|
+
if (currentStdout.length > lastStdoutLength) {
|
|
689
|
+
signals.push({ type: 'stdout_grew', delta: currentStdout.length - lastStdoutLength });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Check target directory for recent modifications
|
|
694
|
+
if (worktreePath && fileExists(worktreePath)) {
|
|
695
|
+
try {
|
|
696
|
+
const result = spawnSync('git', ['diff', '--name-only', '--since', `${lookbackSeconds}.seconds.ago`], {
|
|
697
|
+
cwd: worktreePath,
|
|
698
|
+
encoding: 'utf8',
|
|
699
|
+
timeout: 10_000,
|
|
700
|
+
});
|
|
701
|
+
const changedFiles = (result.stdout ?? '').trim().split('\n').filter(Boolean);
|
|
702
|
+
if (changedFiles.length > 0) {
|
|
703
|
+
signals.push({ type: 'files_changed', count: changedFiles.length, files: changedFiles.slice(0, 3) });
|
|
704
|
+
}
|
|
705
|
+
} catch {}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
hasProgress: signals.length > 0,
|
|
710
|
+
signals,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Async agent runner with progress-based timeout extension.
|
|
716
|
+
*
|
|
717
|
+
* Implements soft/hard timeout model:
|
|
718
|
+
* - softTimeout: check for progress, extend if evidence found
|
|
719
|
+
* - hardTimeout: force terminate regardless of progress
|
|
720
|
+
* - maxExtensions: limit total extensions to prevent infinite loops
|
|
721
|
+
*/
|
|
722
|
+
function runAgentWithProgressCheck({
|
|
723
|
+
cwd,
|
|
724
|
+
agent,
|
|
725
|
+
model,
|
|
726
|
+
prompt,
|
|
727
|
+
timeoutSeconds = 1800,
|
|
728
|
+
promptDir = cwd,
|
|
729
|
+
runDir = null,
|
|
730
|
+
roleLabel = agent,
|
|
731
|
+
worktreePath = null,
|
|
732
|
+
worklogPath = null,
|
|
733
|
+
softTimeoutRatio = 0.67, // soft timeout at 67% of hard timeout
|
|
734
|
+
extensionSeconds = null, // defaults to a timeout-scaled extension window
|
|
735
|
+
maxExtensions = 2, // max 2 extensions unless caller overrides
|
|
736
|
+
progressCheckIntervalSeconds = 60,
|
|
737
|
+
}) {
|
|
738
|
+
return new Promise((resolve, reject) => {
|
|
739
|
+
const scaledExtensionSeconds = extensionSeconds ?? Math.max(30, Math.min(300, Math.floor(timeoutSeconds / 2)));
|
|
740
|
+
const hardTimeoutSeconds = timeoutSeconds + (scaledExtensionSeconds * maxExtensions);
|
|
741
|
+
const softTimeoutSeconds = Math.floor(timeoutSeconds * softTimeoutRatio);
|
|
742
|
+
|
|
743
|
+
let extensionsUsed = 0;
|
|
744
|
+
let lastStdoutLength = 0;
|
|
745
|
+
let lastProgressCheck = Date.now();
|
|
746
|
+
let settled = false;
|
|
747
|
+
let proc = null;
|
|
748
|
+
let stdout = '';
|
|
749
|
+
let stderr = '';
|
|
750
|
+
|
|
751
|
+
const promptFile = path.join(promptDir, `.ai-sprint-prompt-${process.pid}-${crypto.randomUUID()}.txt`);
|
|
752
|
+
|
|
753
|
+
const cleanup = () => {
|
|
754
|
+
try { fs.rmSync(promptFile, { force: true }); } catch {}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
fs.writeFileSync(promptFile, prompt, 'utf8');
|
|
759
|
+
} catch (writeErr) {
|
|
760
|
+
reject(new Error(`Failed to write prompt file: ${writeErr.message}`));
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Hard timeout - absolute maximum
|
|
765
|
+
const hardTimer = setTimeout(() => {
|
|
766
|
+
if (!settled) {
|
|
767
|
+
settled = true;
|
|
768
|
+
clearTimeout(softTimer);
|
|
769
|
+
clearTimeout(progressTimer);
|
|
770
|
+
cleanup();
|
|
771
|
+
terminateProcessTree(proc?.pid, { runDir, label: roleLabel });
|
|
772
|
+
reject(new Error(`Agent ${agent} hard timeout after ${hardTimeoutSeconds}s (extended ${extensionsUsed} times)`));
|
|
773
|
+
}
|
|
774
|
+
}, hardTimeoutSeconds * 1000);
|
|
775
|
+
|
|
776
|
+
// Soft timeout - check progress and maybe extend
|
|
777
|
+
let softTimer;
|
|
778
|
+
const setupSoftTimeout = (seconds) => {
|
|
779
|
+
clearTimeout(softTimer);
|
|
780
|
+
softTimer = setTimeout(() => {
|
|
781
|
+
if (settled) return;
|
|
782
|
+
|
|
783
|
+
const progress = checkProgressEvidence({
|
|
784
|
+
worktreePath,
|
|
785
|
+
worklogPath,
|
|
786
|
+
lastStdoutLength,
|
|
787
|
+
currentStdout: stdout,
|
|
788
|
+
lookbackSeconds: 120,
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
if (progress.hasProgress && extensionsUsed < maxExtensions) {
|
|
792
|
+
extensionsUsed++;
|
|
793
|
+
lastStdoutLength = stdout.length;
|
|
794
|
+
if (runDir) {
|
|
795
|
+
appendTimeline(runDir, `Soft timeout: progress detected for ${roleLabel}, extending by ${scaledExtensionSeconds}s (${progress.signals.map(s => s.type).join(', ')})`);
|
|
796
|
+
}
|
|
797
|
+
setupSoftTimeout(scaledExtensionSeconds);
|
|
798
|
+
} else {
|
|
799
|
+
// No progress or max extensions reached - this becomes effective timeout
|
|
800
|
+
// But don't terminate yet - let hard timeout handle it
|
|
801
|
+
if (runDir && extensionsUsed >= maxExtensions) {
|
|
802
|
+
appendTimeline(runDir, `Soft timeout: max extensions (${maxExtensions}) reached for ${roleLabel}, waiting for hard timeout`);
|
|
803
|
+
} else if (runDir) {
|
|
804
|
+
appendTimeline(runDir, `Soft timeout: no progress detected for ${roleLabel}, waiting for hard timeout`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}, seconds * 1000);
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Progress monitoring timer
|
|
811
|
+
let progressTimer;
|
|
812
|
+
const setupProgressMonitor = () => {
|
|
813
|
+
clearTimeout(progressTimer);
|
|
814
|
+
progressTimer = setTimeout(() => {
|
|
815
|
+
if (settled) return;
|
|
816
|
+
|
|
817
|
+
const progress = checkProgressEvidence({
|
|
818
|
+
worktreePath,
|
|
819
|
+
worklogPath,
|
|
820
|
+
lastStdoutLength,
|
|
821
|
+
currentStdout: stdout,
|
|
822
|
+
lookbackSeconds: 60,
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
if (progress.hasProgress) {
|
|
826
|
+
lastStdoutLength = stdout.length;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (!settled) setupProgressMonitor();
|
|
830
|
+
}, progressCheckIntervalSeconds * 1000);
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
// Spawn the agent process
|
|
834
|
+
try {
|
|
835
|
+
if (process.platform === 'win32') {
|
|
836
|
+
proc = spawn('powershell.exe', [
|
|
837
|
+
'-NoProfile', '-Command',
|
|
838
|
+
`acpx --cwd $env:AI_SPRINT_CWD --approve-all --model $env:AI_SPRINT_MODEL --timeout $env:AI_SPRINT_TIMEOUT ${agent} exec -f $env:AI_SPRINT_PROMPT`,
|
|
839
|
+
], {
|
|
840
|
+
cwd,
|
|
841
|
+
encoding: 'utf8',
|
|
842
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
843
|
+
shell: false,
|
|
844
|
+
env: {
|
|
845
|
+
...process.env,
|
|
846
|
+
AI_SPRINT_CWD: cwd,
|
|
847
|
+
AI_SPRINT_MODEL: model,
|
|
848
|
+
AI_SPRINT_TIMEOUT: String(hardTimeoutSeconds),
|
|
849
|
+
AI_SPRINT_PROMPT: promptFile,
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
} else {
|
|
853
|
+
try {
|
|
854
|
+
proc = spawn(nodeBin, [acpxBin,
|
|
855
|
+
'--cwd', cwd, '--approve-all', '--model', model,
|
|
856
|
+
'--timeout', String(hardTimeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
|
|
857
|
+
], {
|
|
858
|
+
cwd,
|
|
859
|
+
encoding: 'utf8',
|
|
860
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
861
|
+
shell: false,
|
|
862
|
+
detached: true,
|
|
863
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
864
|
+
env: acpxEnv,
|
|
865
|
+
});
|
|
866
|
+
} catch (enoentErr) {
|
|
867
|
+
if (enoentErr.code === 'ENOENT') {
|
|
868
|
+
proc = spawn(nodeBin, [acpxBin,
|
|
869
|
+
'--cwd', cwd, '--approve-all', '--model', model,
|
|
870
|
+
'--timeout', String(hardTimeoutSeconds), '--prompt-retries', '2', '--suppress-reads', agent, 'exec', '-f', promptFile,
|
|
871
|
+
], {
|
|
872
|
+
cwd,
|
|
873
|
+
encoding: 'utf8',
|
|
874
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
875
|
+
shell: false,
|
|
876
|
+
detached: true,
|
|
877
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
878
|
+
env: acpxEnv,
|
|
879
|
+
});
|
|
880
|
+
} else {
|
|
881
|
+
throw enoentErr;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
} catch (spawnErr) {
|
|
886
|
+
settled = true;
|
|
887
|
+
clearTimeout(hardTimer);
|
|
888
|
+
clearTimeout(softTimer);
|
|
889
|
+
clearTimeout(progressTimer);
|
|
890
|
+
cleanup();
|
|
891
|
+
reject(new Error(`Failed to spawn agent ${agent}: ${spawnErr.message}`));
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
proc.stdout.on('data', (data) => { stdout += data; });
|
|
896
|
+
proc.stderr.on('data', (data) => { stderr += data; });
|
|
897
|
+
|
|
898
|
+
proc.on('exit', (code) => {
|
|
899
|
+
if (!settled) {
|
|
900
|
+
settled = true;
|
|
901
|
+
clearTimeout(hardTimer);
|
|
902
|
+
clearTimeout(softTimer);
|
|
903
|
+
clearTimeout(progressTimer);
|
|
904
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), status: code, extensionsUsed });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
proc.on('close', () => { cleanup(); });
|
|
909
|
+
|
|
910
|
+
proc.on('error', (err) => {
|
|
911
|
+
if (!settled) {
|
|
912
|
+
settled = true;
|
|
913
|
+
clearTimeout(hardTimer);
|
|
914
|
+
clearTimeout(softTimer);
|
|
915
|
+
clearTimeout(progressTimer);
|
|
916
|
+
cleanup();
|
|
917
|
+
reject(err);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// Start timers
|
|
922
|
+
setupSoftTimeout(softTimeoutSeconds);
|
|
923
|
+
setupProgressMonitor();
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function roleConfig(spec, role) {
|
|
928
|
+
if (role === 'producer') return spec.producer;
|
|
929
|
+
if (role === 'reviewer_a') return spec.reviewerA;
|
|
930
|
+
if (role === 'reviewer_b') return spec.reviewerB;
|
|
931
|
+
if (role === 'global_reviewer') return spec.escalationReviewer ?? null;
|
|
932
|
+
throw new Error(`Unknown role: ${role}`);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function roleAttemptConfigs(config) {
|
|
936
|
+
if (!config) return [];
|
|
937
|
+
const primary = { ...config, fallback: undefined, attemptLabel: 'primary' };
|
|
938
|
+
const attempts = [primary];
|
|
939
|
+
|
|
940
|
+
if (config.fallback?.agent && config.fallback?.model) {
|
|
941
|
+
attempts.push({
|
|
942
|
+
...config,
|
|
943
|
+
...config.fallback,
|
|
944
|
+
fallback: undefined,
|
|
945
|
+
attemptLabel: 'fallback',
|
|
946
|
+
});
|
|
947
|
+
} else if (config.retryOnce === true) {
|
|
948
|
+
attempts.push({
|
|
949
|
+
...config,
|
|
950
|
+
fallback: undefined,
|
|
951
|
+
attemptLabel: 'retry',
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
return attempts;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function loadSpec(state, args) {
|
|
959
|
+
const specPath = (args && args.taskSpec) || (state && state.specPath) || null;
|
|
960
|
+
return getTaskSpec(state.taskId, specPath);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Resolve per-role timeout for a given stage.
|
|
965
|
+
* Priority: spec.stageRoleTimeouts[stage][role] > config.timeoutSeconds > 600
|
|
966
|
+
*/
|
|
967
|
+
function stageRoleTimeout(spec, stage, role) {
|
|
968
|
+
if (spec.stageRoleTimeouts?.[stage]?.[role] != null) {
|
|
969
|
+
return spec.stageRoleTimeouts[stage][role];
|
|
970
|
+
}
|
|
971
|
+
const config = roleConfig(spec, role);
|
|
972
|
+
return config?.timeoutSeconds ?? 600;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function ensureStagePaths(runDir, stageIndex, stageName) {
|
|
976
|
+
const stageDir = path.join(runDir, 'stages', `${String(stageIndex + 1).padStart(2, '0')}-${stageName}`);
|
|
977
|
+
ensureDir(stageDir);
|
|
978
|
+
const paths = {
|
|
979
|
+
stageDir,
|
|
980
|
+
briefPath: path.join(stageDir, 'brief.md'),
|
|
981
|
+
producerPath: path.join(stageDir, 'producer.md'),
|
|
982
|
+
reviewerAPath: path.join(stageDir, 'reviewer-a.md'),
|
|
983
|
+
reviewerBPath: path.join(stageDir, 'reviewer-b.md'),
|
|
984
|
+
globalReviewerPath: path.join(stageDir, 'global-reviewer.md'),
|
|
985
|
+
decisionPath: path.join(stageDir, 'decision.md'),
|
|
986
|
+
scorecardPath: path.join(stageDir, 'scorecard.json'),
|
|
987
|
+
producerStdoutPath: path.join(stageDir, 'producer-stdout.log'),
|
|
988
|
+
reviewerAStdoutPath: path.join(stageDir, 'reviewer-a-stdout.log'),
|
|
989
|
+
reviewerBStdoutPath: path.join(stageDir, 'reviewer-b-stdout.log'),
|
|
990
|
+
globalReviewerStdoutPath: path.join(stageDir, 'global-reviewer-stdout.log'),
|
|
991
|
+
producerWorklogPath: path.join(stageDir, 'producer-worklog.md'),
|
|
992
|
+
reviewerAWorklogPath: path.join(stageDir, 'reviewer-a-worklog.md'),
|
|
993
|
+
reviewerBWorklogPath: path.join(stageDir, 'reviewer-b-worklog.md'),
|
|
994
|
+
globalReviewerWorklogPath: path.join(stageDir, 'global-reviewer-worklog.md'),
|
|
995
|
+
producerStatePath: path.join(stageDir, 'producer-state.json'),
|
|
996
|
+
reviewerAStatePath: path.join(stageDir, 'reviewer-a-state.json'),
|
|
997
|
+
reviewerBStatePath: path.join(stageDir, 'reviewer-b-state.json'),
|
|
998
|
+
globalReviewerStatePath: path.join(stageDir, 'global-reviewer-state.json'),
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const placeholderFiles = [
|
|
1002
|
+
paths.producerWorklogPath,
|
|
1003
|
+
paths.reviewerAWorklogPath,
|
|
1004
|
+
paths.reviewerBWorklogPath,
|
|
1005
|
+
paths.globalReviewerWorklogPath,
|
|
1006
|
+
];
|
|
1007
|
+
for (const file of placeholderFiles) {
|
|
1008
|
+
if (!fileExists(file)) {
|
|
1009
|
+
writeText(file, '# Worklog\n\n');
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const placeholderStates = [
|
|
1014
|
+
[paths.producerStatePath, 'producer'],
|
|
1015
|
+
[paths.reviewerAStatePath, 'reviewer_a'],
|
|
1016
|
+
[paths.reviewerBStatePath, 'reviewer_b'],
|
|
1017
|
+
[paths.globalReviewerStatePath, 'global_reviewer'],
|
|
1018
|
+
];
|
|
1019
|
+
for (const [file, role] of placeholderStates) {
|
|
1020
|
+
if (!fileExists(file)) {
|
|
1021
|
+
writeJson(file, {
|
|
1022
|
+
role,
|
|
1023
|
+
stage: stageName,
|
|
1024
|
+
round: 0,
|
|
1025
|
+
status: 'idle',
|
|
1026
|
+
lastPid: null,
|
|
1027
|
+
startedAt: null,
|
|
1028
|
+
finishedAt: null,
|
|
1029
|
+
terminatedAt: null,
|
|
1030
|
+
timeoutSeconds: null,
|
|
1031
|
+
lastError: null,
|
|
1032
|
+
checklist: [],
|
|
1033
|
+
updatedAt: nowIso(),
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
return paths;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function roleArtifactPaths(paths, role) {
|
|
1042
|
+
if (role === 'producer') {
|
|
1043
|
+
return { reportPath: paths.producerPath, stdoutPath: paths.producerStdoutPath };
|
|
1044
|
+
}
|
|
1045
|
+
if (role === 'reviewer_a') {
|
|
1046
|
+
return { reportPath: paths.reviewerAPath, stdoutPath: paths.reviewerAStdoutPath };
|
|
1047
|
+
}
|
|
1048
|
+
if (role === 'reviewer_b') {
|
|
1049
|
+
return { reportPath: paths.reviewerBPath, stdoutPath: paths.reviewerBStdoutPath };
|
|
1050
|
+
}
|
|
1051
|
+
if (role === 'global_reviewer') {
|
|
1052
|
+
return { reportPath: paths.globalReviewerPath, stdoutPath: paths.globalReviewerStdoutPath };
|
|
1053
|
+
}
|
|
1054
|
+
throw new Error(`Unknown role: ${role}`);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function roleStatePath(paths, role) {
|
|
1058
|
+
if (role === 'producer') return paths.producerStatePath;
|
|
1059
|
+
if (role === 'reviewer_a') return paths.reviewerAStatePath;
|
|
1060
|
+
if (role === 'reviewer_b') return paths.reviewerBStatePath;
|
|
1061
|
+
if (role === 'global_reviewer') return paths.globalReviewerStatePath;
|
|
1062
|
+
throw new Error(`Unknown role: ${role}`);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function updateRoleState(paths, role, patch) {
|
|
1066
|
+
const statePath = roleStatePath(paths, role);
|
|
1067
|
+
const previous = fileExists(statePath) ? readJson(statePath) : { role };
|
|
1068
|
+
writeJson(statePath, {
|
|
1069
|
+
...previous,
|
|
1070
|
+
...patch,
|
|
1071
|
+
role,
|
|
1072
|
+
updatedAt: nowIso(),
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function terminateProcessTree(pid, { runDir = null, label = 'process' } = {}) {
|
|
1077
|
+
if (!pid) return false;
|
|
1078
|
+
try {
|
|
1079
|
+
if (process.platform === 'win32') {
|
|
1080
|
+
const result = spawnSync('taskkill', ['/T', '/F', '/PID', String(pid)], {
|
|
1081
|
+
encoding: 'utf8',
|
|
1082
|
+
timeout: 30_000,
|
|
1083
|
+
shell: false,
|
|
1084
|
+
});
|
|
1085
|
+
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
|
1086
|
+
const success = result.status === 0 || /not found|no running instance|not found/i.test(output);
|
|
1087
|
+
if (runDir) appendTimeline(runDir, `${success ? 'Terminated' : 'Failed to terminate'} ${label} process tree pid=${pid}${output ? ` (${output})` : ''}`);
|
|
1088
|
+
return success;
|
|
1089
|
+
}
|
|
1090
|
+
// Kill entire process group — negative pid sends signal to all processes
|
|
1091
|
+
// in the same process group. Fallback to direct pid kill if group kill
|
|
1092
|
+
// fails (e.g., process group already destroyed).
|
|
1093
|
+
try {
|
|
1094
|
+
process.kill(-pid, 'SIGKILL');
|
|
1095
|
+
if (runDir) appendTimeline(runDir, `Terminated ${label} process group pgid=${pid}`);
|
|
1096
|
+
} catch (groupErr) {
|
|
1097
|
+
if (groupErr.code === 'ESRCH') {
|
|
1098
|
+
// Process already dead — expected during normal cleanup
|
|
1099
|
+
if (runDir) appendTimeline(runDir, `${label} process already exited pid=${pid}`);
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1102
|
+
process.kill(pid, 'SIGKILL');
|
|
1103
|
+
if (runDir) appendTimeline(runDir, `Terminated ${label} pid=${pid} (group kill failed: ${groupErr.message})`);
|
|
1104
|
+
}
|
|
1105
|
+
return true;
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
if (err.code === 'ESRCH') {
|
|
1108
|
+
// Process already dead — expected during normal cleanup
|
|
1109
|
+
if (runDir) appendTimeline(runDir, `${label} process already exited pid=${pid}`);
|
|
1110
|
+
return true;
|
|
1111
|
+
}
|
|
1112
|
+
if (runDir) appendTimeline(runDir, `Failed to terminate ${label} pid=${pid}: ${err.message}`);
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function cleanupRecordedRoleProcesses(paths, runDir) {
|
|
1118
|
+
const roles = ['producer', 'reviewer_a', 'reviewer_b', 'global_reviewer'];
|
|
1119
|
+
for (const role of roles) {
|
|
1120
|
+
const statePath = roleStatePath(paths, role);
|
|
1121
|
+
if (!fileExists(statePath)) continue;
|
|
1122
|
+
const roleState = readJson(statePath);
|
|
1123
|
+
if (roleState.lastPid) {
|
|
1124
|
+
const terminated = terminateProcessTree(roleState.lastPid, { runDir, label: role });
|
|
1125
|
+
if (terminated) {
|
|
1126
|
+
updateRoleState(paths, role, {
|
|
1127
|
+
status: roleState.status === 'completed' ? roleState.status : 'terminated',
|
|
1128
|
+
terminatedAt: nowIso(),
|
|
1129
|
+
lastPid: null,
|
|
1130
|
+
});
|
|
1131
|
+
} else {
|
|
1132
|
+
updateRoleState(paths, role, {
|
|
1133
|
+
lastError: roleState.lastError ?? `Failed to terminate recorded ${role} pid ${roleState.lastPid}`,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* ISOLATION ARTIFACT ALLOWLIST
|
|
1142
|
+
* Only these files can be collected from isolation directory to stage directory.
|
|
1143
|
+
* This prevents accidental collection of logs, temp files, or other runtime artifacts.
|
|
1144
|
+
*/
|
|
1145
|
+
export const ISOLATION_COLLECT_ALLOWLIST = [
|
|
1146
|
+
// Report files - the primary artifacts we want to collect
|
|
1147
|
+
'report.md',
|
|
1148
|
+
// Stage-specific report filenames
|
|
1149
|
+
'producer.md',
|
|
1150
|
+
'reviewer-a.md',
|
|
1151
|
+
'reviewer-b.md',
|
|
1152
|
+
'global-reviewer.md',
|
|
1153
|
+
];
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Check if a filename is allowed to be collected from isolation directory.
|
|
1157
|
+
* @param {string} filename - The filename to check (basename only, no path)
|
|
1158
|
+
* @returns {boolean} True if the file is in the allowlist
|
|
1159
|
+
*/
|
|
1160
|
+
export function isIsolationCollectAllowed(filename) {
|
|
1161
|
+
return ISOLATION_COLLECT_ALLOWLIST.includes(filename);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Get the isolation directory path for a specific run/stage/role.
|
|
1166
|
+
* This is the canonical isolation directory format: runtime/tmp/sprint-agent/{runId}/{stage}-{role}/
|
|
1167
|
+
*
|
|
1168
|
+
* @param {string} runId - The unique run identifier (e.g., "2026-04-02T14-24-34-009Z-task-name")
|
|
1169
|
+
* @param {string} stageName - The stage name (e.g., "implement", "verify")
|
|
1170
|
+
* @param {string} role - The role name (e.g., "producer", "reviewer_a")
|
|
1171
|
+
* @returns {string} The isolation directory path
|
|
1172
|
+
*/
|
|
1173
|
+
export function getIsolationDir(runId, stageName, role) {
|
|
1174
|
+
const roleDir = `${stageName}-${role}`;
|
|
1175
|
+
return path.join(tempRoot, 'sprint-agent', runId, roleDir);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Find report in iflow isolation directory.
|
|
1180
|
+
* iflow writes to runtime/tmp/sprint-agent/{runId}/{stage}-{role}/{report}.md
|
|
1181
|
+
*
|
|
1182
|
+
* IMPORTANT: Uses runId directly for isolation lookup, not fragile timestamp extraction.
|
|
1183
|
+
* This ensures different runs have unique isolation directories and prevents cross-contamination.
|
|
1184
|
+
*
|
|
1185
|
+
* @param {object} options
|
|
1186
|
+
* @param {string} options.runId - The unique run identifier
|
|
1187
|
+
* @param {string} options.stageName - The stage name
|
|
1188
|
+
* @param {string} options.role - The role name
|
|
1189
|
+
* @param {string} options.reportFilename - The report filename to look for
|
|
1190
|
+
* @returns {string|null} The isolation report path if found and valid, null otherwise
|
|
1191
|
+
*/
|
|
1192
|
+
export function findIsolationReport({ runId, stageName, role, reportFilename }) {
|
|
1193
|
+
if (!runId) return null;
|
|
1194
|
+
|
|
1195
|
+
const isolationDir = getIsolationDir(runId, stageName, role);
|
|
1196
|
+
const isolationReportPath = path.join(isolationDir, reportFilename);
|
|
1197
|
+
|
|
1198
|
+
if (fileExists(isolationReportPath)) {
|
|
1199
|
+
const content = fs.readFileSync(isolationReportPath, 'utf8').trim();
|
|
1200
|
+
// Only return if it contains actual report content (not just session log)
|
|
1201
|
+
if (content && (content.includes('## VERDICT') || content.includes('## SUMMARY'))) {
|
|
1202
|
+
return isolationReportPath;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/**
|
|
1209
|
+
* Collect allowed artifacts from isolation directory to stage directory.
|
|
1210
|
+
* Only files in ISOLATION_COLLECT_ALLOWLIST will be collected.
|
|
1211
|
+
*
|
|
1212
|
+
* @param {object} options
|
|
1213
|
+
* @param {string} options.runId - The unique run identifier
|
|
1214
|
+
* @param {string} options.stageName - The stage name
|
|
1215
|
+
* @param {string} options.role - The role name
|
|
1216
|
+
* @param {string} options.stageDir - The stage directory to collect to
|
|
1217
|
+
* @param {string} options.reportFilename - The expected report filename
|
|
1218
|
+
* @param {string} options.runDir - The run directory (for timeline logging)
|
|
1219
|
+
* @returns {{collected: string[], skipped: string[], isolationReportPath: string|null}} Lists of collected and skipped files, plus the isolation report path if valid
|
|
1220
|
+
*/
|
|
1221
|
+
export function collectIsolationArtifacts({ runId, stageName, role, stageDir, reportFilename, runDir }) {
|
|
1222
|
+
const result = { collected: [], skipped: [], isolationReportPath: null };
|
|
1223
|
+
|
|
1224
|
+
if (!runId) return result;
|
|
1225
|
+
|
|
1226
|
+
const isolationDir = getIsolationDir(runId, stageName, role);
|
|
1227
|
+
if (!fileExists(isolationDir)) return result;
|
|
1228
|
+
|
|
1229
|
+
// Only collect the specific report file if it's in the allowlist
|
|
1230
|
+
if (!isIsolationCollectAllowed(reportFilename)) {
|
|
1231
|
+
result.skipped.push(reportFilename);
|
|
1232
|
+
return result;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const isolationReportPath = path.join(isolationDir, reportFilename);
|
|
1236
|
+
const stageReportPath = path.join(stageDir, reportFilename);
|
|
1237
|
+
|
|
1238
|
+
if (!fileExists(isolationReportPath)) return result;
|
|
1239
|
+
|
|
1240
|
+
const isolationContent = fs.readFileSync(isolationReportPath, 'utf8').trim();
|
|
1241
|
+
const stageContent = fileExists(stageReportPath) ? fs.readFileSync(stageReportPath, 'utf8').trim() : '';
|
|
1242
|
+
|
|
1243
|
+
// Only collect if isolation report has actual content and stage report is missing or empty
|
|
1244
|
+
const isolationHasReport = isolationContent && (isolationContent.includes('## VERDICT') || isolationContent.includes('## SUMMARY'));
|
|
1245
|
+
const stageHasReport = stageContent && (stageContent.includes('## VERDICT') || stageContent.includes('## SUMMARY'));
|
|
1246
|
+
|
|
1247
|
+
if (isolationHasReport) {
|
|
1248
|
+
result.isolationReportPath = isolationReportPath; // Always return path when valid content exists
|
|
1249
|
+
if (!stageHasReport) {
|
|
1250
|
+
writeText(stageReportPath, `${isolationContent}\n`);
|
|
1251
|
+
appendTimeline(runDir, `Collected isolation artifact: ${reportFilename}`);
|
|
1252
|
+
result.collected.push(reportFilename);
|
|
1253
|
+
} else {
|
|
1254
|
+
result.skipped.push(reportFilename);
|
|
1255
|
+
}
|
|
1256
|
+
} else {
|
|
1257
|
+
result.skipped.push(reportFilename);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function readRoleOutput({ reportPath, stdout, isolationReportPath = null }) {
|
|
1264
|
+
// First try the expected report path
|
|
1265
|
+
if (fileExists(reportPath)) {
|
|
1266
|
+
const report = fs.readFileSync(reportPath, 'utf8').trim();
|
|
1267
|
+
if (report) {
|
|
1268
|
+
return report;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// Fallback to isolation directory if provided
|
|
1272
|
+
if (isolationReportPath && fileExists(isolationReportPath)) {
|
|
1273
|
+
const report = fs.readFileSync(isolationReportPath, 'utf8').trim();
|
|
1274
|
+
if (report) {
|
|
1275
|
+
return report;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return String(stdout ?? '').trim();
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function reportExistsAndNonEmpty(reportPath) {
|
|
1282
|
+
return fileExists(reportPath) && Boolean(fs.readFileSync(reportPath, 'utf8').trim());
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const PROTECTED_CRITICAL = ['sprint.json', 'timeline.md', 'latest-summary.md'];
|
|
1286
|
+
const PROTECTED_STAGE_CRITICAL = ['decision.md', 'scorecard.json'];
|
|
1287
|
+
|
|
1288
|
+
function protectedArtifacts(runDir, paths) {
|
|
1289
|
+
// Roles must not modify stage-level truth sources that are written only after role execution.
|
|
1290
|
+
// Do not include run-level timeline/latest-summary here: the orchestrator itself updates those
|
|
1291
|
+
// while roles are still running (timeouts, progress signals, state transitions), which would
|
|
1292
|
+
// create false protected-file violations.
|
|
1293
|
+
return [
|
|
1294
|
+
path.join(runDir, 'sprint.json'),
|
|
1295
|
+
paths.decisionPath,
|
|
1296
|
+
paths.scorecardPath,
|
|
1297
|
+
];
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function classifyProtectedFile(filePath, runDir) {
|
|
1301
|
+
const basename = path.basename(filePath);
|
|
1302
|
+
if (!runDir) {
|
|
1303
|
+
if (PROTECTED_CRITICAL.includes(basename) || PROTECTED_STAGE_CRITICAL.includes(basename)) {
|
|
1304
|
+
return 'critical';
|
|
1305
|
+
}
|
|
1306
|
+
return 'warn';
|
|
1307
|
+
}
|
|
1308
|
+
const stageDir = path.join(runDir, 'stages');
|
|
1309
|
+
if (filePath.startsWith(stageDir)) {
|
|
1310
|
+
return PROTECTED_STAGE_CRITICAL.includes(basename) ? 'critical' : 'warn';
|
|
1311
|
+
}
|
|
1312
|
+
return PROTECTED_CRITICAL.includes(basename) ? 'critical' : 'warn';
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function snapshotProtectedFiles(files) {
|
|
1316
|
+
const snapshot = {};
|
|
1317
|
+
for (const file of files) {
|
|
1318
|
+
snapshot[file] = fileExists(file) ? fs.statSync(file).mtimeMs : null;
|
|
1319
|
+
}
|
|
1320
|
+
return snapshot;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function detectProtectedWriteViolation(files, snapshot, runDir) {
|
|
1324
|
+
for (const file of files) {
|
|
1325
|
+
const previous = snapshot[file] ?? null;
|
|
1326
|
+
const current = fileExists(file) ? fs.statSync(file).mtimeMs : null;
|
|
1327
|
+
if (previous !== current) {
|
|
1328
|
+
return { file, severity: classifyProtectedFile(file, runDir) };
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
return null;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Format a role-level validation result for decision.md.
|
|
1336
|
+
* @param {string} roleName - Display name (e.g., "Producer", "Reviewer A")
|
|
1337
|
+
* @param {{valid: boolean, missingSections?: string[], invalidFields?: string[], errorSummary?: string|null}} roleResult
|
|
1338
|
+
* @returns {string[]} Lines for decision.md
|
|
1339
|
+
*/
|
|
1340
|
+
export function formatRoleValidation(roleName, roleResult) {
|
|
1341
|
+
if (!roleResult) return [];
|
|
1342
|
+
const lines = [];
|
|
1343
|
+
const status = roleResult.valid ? '[OK]' : '[FAIL]';
|
|
1344
|
+
const mainLine = `- ${roleName}: ${status}`;
|
|
1345
|
+
|
|
1346
|
+
if (roleResult.valid) {
|
|
1347
|
+
lines.push(mainLine);
|
|
1348
|
+
} else {
|
|
1349
|
+
// Show error summary if available
|
|
1350
|
+
const details = [];
|
|
1351
|
+
if (roleResult.missingSections?.length) {
|
|
1352
|
+
details.push(`missing: ${roleResult.missingSections.join(', ')}`);
|
|
1353
|
+
}
|
|
1354
|
+
if (roleResult.invalidFields?.length) {
|
|
1355
|
+
details.push(`invalid: ${roleResult.invalidFields.join(', ')}`);
|
|
1356
|
+
}
|
|
1357
|
+
lines.push(`${mainLine} ${details.join('; ')}`);
|
|
1358
|
+
}
|
|
1359
|
+
return lines;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Validate that a report contains the minimum required markdown section headings.
|
|
1364
|
+
* Returns an array of missing section descriptions (empty = pass).
|
|
1365
|
+
*
|
|
1366
|
+
* WF-003 fix: reviewer report format enforcement — prevents stage_error from
|
|
1367
|
+
* reports that exist on disk but lack required sections.
|
|
1368
|
+
* WF-004 fix: producer report format enforcement — prevents max_rounds_exceeded
|
|
1369
|
+
* by giving explicit feedback about which sections are missing.
|
|
1370
|
+
*/
|
|
1371
|
+
function validateReportSections(text, requiredSections, reportLabel) {
|
|
1372
|
+
const missing = [];
|
|
1373
|
+
for (const section of requiredSections) {
|
|
1374
|
+
// Match ## or ### heading level, case-insensitive, word boundary after section name
|
|
1375
|
+
const safeSection = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1376
|
+
const re = new RegExp(`^#{2,3}\\s+${safeSection}\\b`, 'im');
|
|
1377
|
+
if (!re.test(text)) {
|
|
1378
|
+
missing.push(`${reportLabel}: missing required section "${section}"`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
return missing;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Core reviewer-report minimum schema. Without these sections, the decision
|
|
1386
|
+
* engine cannot produce valid verdicts or blockers, and the stage will fail
|
|
1387
|
+
* with an opaque stage_error.
|
|
1388
|
+
*/
|
|
1389
|
+
const REQUIRED_REVIEWER_SECTIONS = ['VERDICT', 'FINDINGS', 'BLOCKERS', 'NEXT_FOCUS', 'CHECKS'];
|
|
1390
|
+
|
|
1391
|
+
export function decideAndPersist({ runDir, stageName, stageDir, decisionPath, scorecardPath, producerPath, reviewerAPath, reviewerBPath, globalReviewerPath, state, reviewerTimeouts = null, reviewerViolations = null, reviewerFailures = null }) {
|
|
1392
|
+
const spec = loadSpec(state);
|
|
1393
|
+
const workUnit = getActiveWorkUnit(spec, stageName, {
|
|
1394
|
+
workUnitIndex: state.currentWorkUnitIndex ?? 0,
|
|
1395
|
+
});
|
|
1396
|
+
// Build lookup maps for reviewer timeout/error state
|
|
1397
|
+
const timeoutMap = new Map((reviewerTimeouts ?? []).map((r) => [r.role, r]));
|
|
1398
|
+
const failureMap = new Map((reviewerFailures ?? []).map((r) => [r.role, r]));
|
|
1399
|
+
const missingFiles = [];
|
|
1400
|
+
if (!reportExistsAndNonEmpty(producerPath)) missingFiles.push(`producer: ${producerPath}${fileExists(producerPath) ? ' (empty)' : ''}`);
|
|
1401
|
+
if (!reportExistsAndNonEmpty(reviewerAPath)) {
|
|
1402
|
+
const info = timeoutMap.get('reviewer_a');
|
|
1403
|
+
const failure = failureMap.get('reviewer_a');
|
|
1404
|
+
if (info?.timedOut) missingFiles.push(`reviewer_a: ${reviewerAPath} (timed out, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
|
|
1405
|
+
else if (info || failure) missingFiles.push(`reviewer_a: ${reviewerAPath} (error, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
|
|
1406
|
+
else missingFiles.push(`reviewer_a: ${reviewerAPath}${fileExists(reviewerAPath) ? ' (empty)' : ' (missing)'}`);
|
|
1407
|
+
}
|
|
1408
|
+
if (!reportExistsAndNonEmpty(reviewerBPath)) {
|
|
1409
|
+
const info = timeoutMap.get('reviewer_b');
|
|
1410
|
+
const failure = failureMap.get('reviewer_b');
|
|
1411
|
+
if (info?.timedOut) missingFiles.push(`reviewer_b: ${reviewerBPath} (timed out, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
|
|
1412
|
+
else if (info || failure) missingFiles.push(`reviewer_b: ${reviewerBPath} (error, no report${failure?.summary ? `; ${failure.summary}` : ''})`);
|
|
1413
|
+
else missingFiles.push(`reviewer_b: ${reviewerBPath}${fileExists(reviewerBPath) ? ' (empty)' : ' (missing)'}`);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const stageCriteria = spec.stageCriteria?.[stageName] ?? {};
|
|
1417
|
+
const globalReviewerRequired = stageCriteria.globalReviewerRequired === true;
|
|
1418
|
+
if (globalReviewerRequired && globalReviewerPath && !reportExistsAndNonEmpty(globalReviewerPath)) {
|
|
1419
|
+
missingFiles.push(`global_reviewer: ${globalReviewerPath}${fileExists(globalReviewerPath) ? ' (empty — required for this stage)' : ' (missing — required for this stage)'}`);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (missingFiles.length > 0) {
|
|
1423
|
+
const failure = inferFailureClassification({
|
|
1424
|
+
summary: `Missing reports: ${missingFiles.join('; ')}`,
|
|
1425
|
+
blockers: missingFiles,
|
|
1426
|
+
reviewerTimeouts,
|
|
1427
|
+
reviewerViolations,
|
|
1428
|
+
});
|
|
1429
|
+
writeText(decisionPath, [
|
|
1430
|
+
`# Decision`,
|
|
1431
|
+
'',
|
|
1432
|
+
`- Stage: ${stageName}`,
|
|
1433
|
+
`- Round: ${state.currentRound}`,
|
|
1434
|
+
`- Outcome: error`,
|
|
1435
|
+
'',
|
|
1436
|
+
`## Missing reports`,
|
|
1437
|
+
...missingFiles.map((f) => `- ${f}`),
|
|
1438
|
+
'',
|
|
1439
|
+
`Cannot render stage decision because required role reports are missing.`,
|
|
1440
|
+
'',
|
|
1441
|
+
].join('\n'));
|
|
1442
|
+
writeJson(scorecardPath, {
|
|
1443
|
+
stage: stageName,
|
|
1444
|
+
round: state.currentRound,
|
|
1445
|
+
outcome: 'error',
|
|
1446
|
+
summary: `Missing reports: ${missingFiles.join('; ')}`,
|
|
1447
|
+
missingReports: missingFiles,
|
|
1448
|
+
failureClassification: failure.failureClassification,
|
|
1449
|
+
failureSource: failure.failureSource,
|
|
1450
|
+
recommendedNextAction: failure.recommendedNextAction,
|
|
1451
|
+
reviewerTimeouts: reviewerTimeouts ?? null,
|
|
1452
|
+
reviewerFailures: reviewerFailures ?? null,
|
|
1453
|
+
updatedAt: nowIso(),
|
|
1454
|
+
});
|
|
1455
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: error (missing reports)`);
|
|
1456
|
+
updateSummaryWithClassification(runDir, [
|
|
1457
|
+
`Status: ${state.status}`,
|
|
1458
|
+
`Stage: ${stageName}`,
|
|
1459
|
+
`Round: ${state.currentRound}`,
|
|
1460
|
+
`Outcome: error`,
|
|
1461
|
+
`Missing: ${missingFiles.join('; ')}`,
|
|
1462
|
+
], failure);
|
|
1463
|
+
return { outcome: 'error', summary: `Missing reports: ${missingFiles.join('; ')}`, blockers: missingFiles, metrics: {} };
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const producer = fs.readFileSync(producerPath, 'utf8');
|
|
1467
|
+
const reviewerA = fs.readFileSync(reviewerAPath, 'utf8');
|
|
1468
|
+
const reviewerB = fs.readFileSync(reviewerBPath, 'utf8');
|
|
1469
|
+
const globalReviewer = (globalReviewerRequired && globalReviewerPath && reportExistsAndNonEmpty(globalReviewerPath))
|
|
1470
|
+
? fs.readFileSync(globalReviewerPath, 'utf8')
|
|
1471
|
+
: null;
|
|
1472
|
+
|
|
1473
|
+
// Read reviewer state JSON for dimensions fallback (agent may write dimensions to state but not report text)
|
|
1474
|
+
const reviewerAStatePath = path.join(stageDir, 'reviewer-a-state.json');
|
|
1475
|
+
const reviewerBStatePath = path.join(stageDir, 'reviewer-b-state.json');
|
|
1476
|
+
let reviewerADimensionsFallback = null;
|
|
1477
|
+
let reviewerBDimensionsFallback = null;
|
|
1478
|
+
try {
|
|
1479
|
+
if (fileExists(reviewerAStatePath)) {
|
|
1480
|
+
const aState = readJson(reviewerAStatePath);
|
|
1481
|
+
if (aState.dimensions && typeof aState.dimensions === 'object') reviewerADimensionsFallback = aState.dimensions;
|
|
1482
|
+
}
|
|
1483
|
+
} catch {}
|
|
1484
|
+
try {
|
|
1485
|
+
if (fileExists(reviewerBStatePath)) {
|
|
1486
|
+
const bState = readJson(reviewerBStatePath);
|
|
1487
|
+
if (bState.dimensions && typeof bState.dimensions === 'object') reviewerBDimensionsFallback = bState.dimensions;
|
|
1488
|
+
}
|
|
1489
|
+
} catch {}
|
|
1490
|
+
|
|
1491
|
+
// WF-003/WF-004 fix: validate report schema before entering decision engine.
|
|
1492
|
+
// Reports that exist on disk but lack required sections cause opaque stage_error
|
|
1493
|
+
// or silent max_rounds_exceeded. By validating here, we get explicit error
|
|
1494
|
+
// messages that tell the agent exactly which sections are missing.
|
|
1495
|
+
//
|
|
1496
|
+
// Return 'error' outcome (which advanceState() maps to stage_error → halt).
|
|
1497
|
+
// This is intentional: if both reviewers fail schema validation simultaneously,
|
|
1498
|
+
// the stage cannot proceed. The explicit blockers tell the Executor exactly
|
|
1499
|
+
// what to fix in the next sprint.
|
|
1500
|
+
const schemaErrors = [];
|
|
1501
|
+
const missingReviewerA = validateReportSections(reviewerA, REQUIRED_REVIEWER_SECTIONS, 'reviewer_a');
|
|
1502
|
+
const missingReviewerB = validateReportSections(reviewerB, REQUIRED_REVIEWER_SECTIONS, 'reviewer_b');
|
|
1503
|
+
if (missingReviewerA.length) schemaErrors.push(...missingReviewerA);
|
|
1504
|
+
if (missingReviewerB.length) schemaErrors.push(...missingReviewerB);
|
|
1505
|
+
|
|
1506
|
+
if (schemaErrors.length > 0) {
|
|
1507
|
+
const errorSummary = `Report schema violation: ${schemaErrors.join('; ')}`;
|
|
1508
|
+
const failure = inferFailureClassification({
|
|
1509
|
+
summary: errorSummary,
|
|
1510
|
+
blockers: schemaErrors,
|
|
1511
|
+
reviewerTimeouts,
|
|
1512
|
+
reviewerViolations,
|
|
1513
|
+
});
|
|
1514
|
+
writeText(decisionPath, [
|
|
1515
|
+
`# Decision`,
|
|
1516
|
+
'',
|
|
1517
|
+
`- Stage: ${stageName}`,
|
|
1518
|
+
`- Round: ${state.currentRound}`,
|
|
1519
|
+
`- Outcome: error`,
|
|
1520
|
+
'',
|
|
1521
|
+
`## Report Schema Violation`,
|
|
1522
|
+
...schemaErrors.map((e) => `- ${e}`),
|
|
1523
|
+
'',
|
|
1524
|
+
`Cannot render stage decision because role reports are missing required sections.`,
|
|
1525
|
+
'',
|
|
1526
|
+
].join('\n'));
|
|
1527
|
+
writeJson(scorecardPath, {
|
|
1528
|
+
stage: stageName,
|
|
1529
|
+
round: state.currentRound,
|
|
1530
|
+
outcome: 'error',
|
|
1531
|
+
summary: errorSummary,
|
|
1532
|
+
missingReports: schemaErrors,
|
|
1533
|
+
failureClassification: failure.failureClassification,
|
|
1534
|
+
failureSource: failure.failureSource,
|
|
1535
|
+
recommendedNextAction: failure.recommendedNextAction,
|
|
1536
|
+
reviewerTimeouts: reviewerTimeouts ?? null,
|
|
1537
|
+
updatedAt: nowIso(),
|
|
1538
|
+
schemaValidation: {
|
|
1539
|
+
reviewerA_sections: REQUIRED_REVIEWER_SECTIONS,
|
|
1540
|
+
reviewerA_missing: missingReviewerA,
|
|
1541
|
+
reviewerB_sections: REQUIRED_REVIEWER_SECTIONS,
|
|
1542
|
+
reviewerB_missing: missingReviewerB,
|
|
1543
|
+
},
|
|
1544
|
+
});
|
|
1545
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: error (report schema violation)`);
|
|
1546
|
+
updateSummaryWithClassification(runDir, [
|
|
1547
|
+
`Status: ${state.status}`,
|
|
1548
|
+
`Stage: ${stageName}`,
|
|
1549
|
+
`Round: ${state.currentRound}`,
|
|
1550
|
+
`Outcome: error`,
|
|
1551
|
+
`Schema violations: ${schemaErrors.join('; ')}`,
|
|
1552
|
+
], failure);
|
|
1553
|
+
return { outcome: 'error', summary: errorSummary, blockers: schemaErrors, metrics: {} };
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const decision = decideStage({
|
|
1557
|
+
stageCriteria: spec.stageCriteria?.[stageName],
|
|
1558
|
+
producer,
|
|
1559
|
+
reviewerA,
|
|
1560
|
+
reviewerB,
|
|
1561
|
+
globalReviewer: globalReviewerRequired ? globalReviewer : null,
|
|
1562
|
+
currentRound: state.currentRound,
|
|
1563
|
+
maxRoundsPerStage: state.maxRoundsPerStage,
|
|
1564
|
+
reviewerViolations,
|
|
1565
|
+
reviewerADimensionsFallback,
|
|
1566
|
+
reviewerBDimensionsFallback,
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
const content = [
|
|
1570
|
+
`# Decision`,
|
|
1571
|
+
'',
|
|
1572
|
+
`- Stage: ${stageName}`,
|
|
1573
|
+
`- Round: ${state.currentRound}`,
|
|
1574
|
+
`- Outcome: ${decision.outcome}`,
|
|
1575
|
+
`- Output Quality: ${decision.outputQuality}`,
|
|
1576
|
+
...(workUnit
|
|
1577
|
+
? [
|
|
1578
|
+
`- Work Unit: ${workUnit.workUnitId}`,
|
|
1579
|
+
`- Work Unit Goal: ${workUnit.workUnitGoal}`,
|
|
1580
|
+
]
|
|
1581
|
+
: []),
|
|
1582
|
+
'',
|
|
1583
|
+
`## Summary`,
|
|
1584
|
+
decision.summary,
|
|
1585
|
+
'',
|
|
1586
|
+
...(decision.qualityReasons?.length
|
|
1587
|
+
? [
|
|
1588
|
+
`## Quality Reasons`,
|
|
1589
|
+
...decision.qualityReasons.map((r) => `- ${r}`),
|
|
1590
|
+
'',
|
|
1591
|
+
]
|
|
1592
|
+
: []),
|
|
1593
|
+
...(decision.nextRunRecommendation
|
|
1594
|
+
? [
|
|
1595
|
+
`## Next Run Recommendation`,
|
|
1596
|
+
`- Type: ${decision.nextRunRecommendation.type}`,
|
|
1597
|
+
...(decision.nextRunRecommendation.spec
|
|
1598
|
+
? [`- Spec: ${decision.nextRunRecommendation.spec}`]
|
|
1599
|
+
: []),
|
|
1600
|
+
...(decision.nextRunRecommendation.reasons?.length
|
|
1601
|
+
? ['', 'Reasons:', ...decision.nextRunRecommendation.reasons.map((r) => `- ${r}`)]
|
|
1602
|
+
: []),
|
|
1603
|
+
'',
|
|
1604
|
+
]
|
|
1605
|
+
: []),
|
|
1606
|
+
`## Validation`,
|
|
1607
|
+
`- Contract Valid: ${decision.validation?.valid ?? 'n/a'}`,
|
|
1608
|
+
...(decision.validation?.producer
|
|
1609
|
+
? formatRoleValidation('Producer', decision.validation.producer)
|
|
1610
|
+
: []),
|
|
1611
|
+
...(decision.validation?.reviewerA
|
|
1612
|
+
? formatRoleValidation('Reviewer A', decision.validation.reviewerA)
|
|
1613
|
+
: []),
|
|
1614
|
+
...(decision.validation?.reviewerB
|
|
1615
|
+
? formatRoleValidation('Reviewer B', decision.validation.reviewerB)
|
|
1616
|
+
: []),
|
|
1617
|
+
...(decision.validation?.globalReviewer
|
|
1618
|
+
? formatRoleValidation('Global Reviewer', decision.validation.globalReviewer)
|
|
1619
|
+
: []),
|
|
1620
|
+
...(decision.validation?.errorSummary
|
|
1621
|
+
? ['', `### Error Summary`, decision.validation.errorSummary]
|
|
1622
|
+
: []),
|
|
1623
|
+
'',
|
|
1624
|
+
`## Blockers`,
|
|
1625
|
+
...(decision.blockers.length ? decision.blockers.map((b) => `- ${b}`) : ['- None.']),
|
|
1626
|
+
'',
|
|
1627
|
+
`## Metrics`,
|
|
1628
|
+
`- approvalCount: ${decision.metrics.approvalCount}`,
|
|
1629
|
+
`- blockerCount: ${decision.metrics.blockerCount}`,
|
|
1630
|
+
`- reviewerAVerdict: ${decision.metrics.reviewerAVerdict}`,
|
|
1631
|
+
`- reviewerBVerdict: ${decision.metrics.reviewerBVerdict}`,
|
|
1632
|
+
`- producerSectionChecks: ${JSON.stringify(decision.metrics.producerSectionChecks)}`,
|
|
1633
|
+
`- reviewerSectionChecks: ${JSON.stringify(decision.metrics.reviewerSectionChecks)}`,
|
|
1634
|
+
`- producerChecks: ${decision.metrics.producerChecks ?? 'n/a'}`,
|
|
1635
|
+
`- reviewerAChecks: ${decision.metrics.reviewerAChecks ?? 'n/a'}`,
|
|
1636
|
+
`- reviewerBChecks: ${decision.metrics.reviewerBChecks ?? 'n/a'}`,
|
|
1637
|
+
...(decision.metrics.globalReviewerVerdict
|
|
1638
|
+
? [
|
|
1639
|
+
`- globalReviewerVerdict: ${decision.metrics.globalReviewerVerdict}`,
|
|
1640
|
+
`- globalReviewerRequired: true`,
|
|
1641
|
+
`- globalReviewerChecks: ${decision.metrics.globalReviewerChecks ?? 'n/a'}`,
|
|
1642
|
+
`- macroAnswersSatisfied: ${decision.metrics.macroAnswersSatisfied ?? false}`,
|
|
1643
|
+
]
|
|
1644
|
+
: []),
|
|
1645
|
+
...(decision.metrics.scoringDimensions?.length
|
|
1646
|
+
? [
|
|
1647
|
+
`- scoringDimensions: ${decision.metrics.scoringDimensions.join(', ')}`,
|
|
1648
|
+
`- reviewerADimensions: ${JSON.stringify(decision.metrics.reviewerADimensions)}`,
|
|
1649
|
+
`- reviewerBDimensions: ${JSON.stringify(decision.metrics.reviewerBDimensions)}`,
|
|
1650
|
+
`- dimensionFailures: ${decision.metrics.dimensionFailures.length}`,
|
|
1651
|
+
]
|
|
1652
|
+
: []),
|
|
1653
|
+
...(decision.metrics.contractItems?.length
|
|
1654
|
+
? [
|
|
1655
|
+
`- contractDoneItems: ${decision.metrics.contractCheck.doneItems}/${decision.metrics.contractCheck.totalItems}`,
|
|
1656
|
+
]
|
|
1657
|
+
: []),
|
|
1658
|
+
'',
|
|
1659
|
+
`## Files`,
|
|
1660
|
+
`- Producer: ${producerPath}`,
|
|
1661
|
+
`- Reviewer A: ${reviewerAPath}`,
|
|
1662
|
+
`- Reviewer B: ${reviewerBPath}`,
|
|
1663
|
+
...(globalReviewerRequired ? [`- Global Reviewer: ${globalReviewerPath}`] : []),
|
|
1664
|
+
].join('\n');
|
|
1665
|
+
|
|
1666
|
+
writeText(decisionPath, `${content}\n`);
|
|
1667
|
+
|
|
1668
|
+
const failure = decision.outcome === 'error'
|
|
1669
|
+
? inferFailureClassification({
|
|
1670
|
+
summary: decision.summary,
|
|
1671
|
+
blockers: decision.blockers,
|
|
1672
|
+
reviewerTimeouts,
|
|
1673
|
+
reviewerViolations,
|
|
1674
|
+
})
|
|
1675
|
+
: null;
|
|
1676
|
+
|
|
1677
|
+
const scorecard = {
|
|
1678
|
+
stage: stageName,
|
|
1679
|
+
round: state.currentRound,
|
|
1680
|
+
workUnitId: workUnit?.workUnitId ?? null,
|
|
1681
|
+
workUnitGoal: workUnit?.workUnitGoal ?? null,
|
|
1682
|
+
outcome: decision.outcome,
|
|
1683
|
+
outputQuality: decision.outputQuality,
|
|
1684
|
+
qualityReasons: decision.qualityReasons ?? [],
|
|
1685
|
+
nextRunRecommendation: decision.nextRunRecommendation ?? null,
|
|
1686
|
+
validation: {
|
|
1687
|
+
valid: decision.validation?.valid ?? true,
|
|
1688
|
+
errorSummary: decision.validation?.errorSummary ?? null,
|
|
1689
|
+
producer: decision.validation?.producer ?? null,
|
|
1690
|
+
reviewerA: decision.validation?.reviewerA ?? null,
|
|
1691
|
+
reviewerB: decision.validation?.reviewerB ?? null,
|
|
1692
|
+
globalReviewer: decision.validation?.globalReviewer ?? null,
|
|
1693
|
+
},
|
|
1694
|
+
summary: decision.summary,
|
|
1695
|
+
failureClassification: failure?.failureClassification ?? null,
|
|
1696
|
+
failureSource: failure?.failureSource ?? null,
|
|
1697
|
+
recommendedNextAction: failure?.recommendedNextAction ?? null,
|
|
1698
|
+
approvalCount: decision.metrics.approvalCount,
|
|
1699
|
+
blockerCount: decision.metrics.blockerCount,
|
|
1700
|
+
reviewerAVerdict: decision.metrics.reviewerAVerdict,
|
|
1701
|
+
reviewerBVerdict: decision.metrics.reviewerBVerdict,
|
|
1702
|
+
producerSectionChecks: decision.metrics.producerSectionChecks,
|
|
1703
|
+
reviewerSectionChecks: decision.metrics.reviewerSectionChecks,
|
|
1704
|
+
producerChecks: decision.metrics.producerChecks,
|
|
1705
|
+
reviewerAChecks: decision.metrics.reviewerAChecks,
|
|
1706
|
+
reviewerBChecks: decision.metrics.reviewerBChecks,
|
|
1707
|
+
...(decision.metrics.globalReviewerVerdict
|
|
1708
|
+
? {
|
|
1709
|
+
globalReviewerVerdict: decision.metrics.globalReviewerVerdict,
|
|
1710
|
+
globalReviewerRequired: true,
|
|
1711
|
+
globalReviewerChecks: decision.metrics.globalReviewerChecks,
|
|
1712
|
+
macroAnswersSatisfied: decision.metrics.macroAnswersSatisfied,
|
|
1713
|
+
macroAnswersRequired: decision.metrics.macroAnswersRequired ?? [],
|
|
1714
|
+
macroAnswersFound: decision.metrics.macroAnswersFound ?? [],
|
|
1715
|
+
}
|
|
1716
|
+
: {}),
|
|
1717
|
+
blockers: decision.blockers,
|
|
1718
|
+
...(decision.metrics.scoringDimensions?.length
|
|
1719
|
+
? {
|
|
1720
|
+
scoringDimensions: decision.metrics.scoringDimensions,
|
|
1721
|
+
reviewerADimensions: decision.metrics.reviewerADimensions,
|
|
1722
|
+
reviewerBDimensions: decision.metrics.reviewerBDimensions,
|
|
1723
|
+
dimensionFailures: decision.metrics.dimensionFailures,
|
|
1724
|
+
}
|
|
1725
|
+
: {}),
|
|
1726
|
+
...(decision.metrics.contractItems?.length
|
|
1727
|
+
? {
|
|
1728
|
+
contractItems: decision.metrics.contractItems,
|
|
1729
|
+
contractCheck: decision.metrics.contractCheck,
|
|
1730
|
+
}
|
|
1731
|
+
: {}),
|
|
1732
|
+
...(reviewerTimeouts
|
|
1733
|
+
? { reviewerTimeouts }
|
|
1734
|
+
: {}),
|
|
1735
|
+
...(reviewerFailures
|
|
1736
|
+
? { reviewerFailures }
|
|
1737
|
+
: {}),
|
|
1738
|
+
updatedAt: nowIso(),
|
|
1739
|
+
};
|
|
1740
|
+
writeJson(scorecardPath, scorecard);
|
|
1741
|
+
|
|
1742
|
+
// Build and write handoff for next round
|
|
1743
|
+
if (decision.outcome === 'revise') {
|
|
1744
|
+
const handoff = buildHandoff({
|
|
1745
|
+
reviewerA,
|
|
1746
|
+
reviewerB,
|
|
1747
|
+
globalReviewer,
|
|
1748
|
+
producer,
|
|
1749
|
+
metrics: decision.metrics,
|
|
1750
|
+
stageName,
|
|
1751
|
+
round: state.currentRound,
|
|
1752
|
+
workUnit,
|
|
1753
|
+
});
|
|
1754
|
+
const handoffPath = path.join(stageDir, 'handoff.json');
|
|
1755
|
+
writeJson(handoffPath, handoff);
|
|
1756
|
+
writeCheckpointSummary(stageDir, {
|
|
1757
|
+
decision,
|
|
1758
|
+
handoff,
|
|
1759
|
+
producer,
|
|
1760
|
+
reviewerA,
|
|
1761
|
+
reviewerB,
|
|
1762
|
+
globalReviewer: globalReviewer ?? '',
|
|
1763
|
+
});
|
|
1764
|
+
} else {
|
|
1765
|
+
writeCheckpointSummary(stageDir, {
|
|
1766
|
+
decision,
|
|
1767
|
+
handoff: null,
|
|
1768
|
+
producer,
|
|
1769
|
+
reviewerA,
|
|
1770
|
+
reviewerB,
|
|
1771
|
+
globalReviewer: globalReviewer ?? '',
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} decision: ${decision.outcome}`);
|
|
1775
|
+
updateSummaryWithClassification(runDir, [
|
|
1776
|
+
`Status: ${state.status}`,
|
|
1777
|
+
`Stage: ${stageName}`,
|
|
1778
|
+
`Round: ${state.currentRound}`,
|
|
1779
|
+
`Outcome: ${decision.outcome}`,
|
|
1780
|
+
`Approval count: ${decision.metrics.approvalCount}/2${decision.metrics.globalReviewerVerdict ? ` + global_reviewer: ${decision.metrics.globalReviewerVerdict}` : ''}`,
|
|
1781
|
+
`Blocker count: ${decision.metrics.blockerCount}`,
|
|
1782
|
+
...(decision.blockers.length ? [`Top blocker: ${decision.blockers[0]}`] : ['Top blocker: none']),
|
|
1783
|
+
], failure);
|
|
1784
|
+
return decision;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Run merge gate: fetch origin and compare local vs remote PR/target branch HEAD SHA.
|
|
1789
|
+
*
|
|
1790
|
+
* IMPORTANT — targetBranch vs worktree.branchName:
|
|
1791
|
+
* - targetBranch = spec.branch ?? 'main' ← the REAL PR branch on remote (e.g. 'feat/my-feature')
|
|
1792
|
+
* - worktree.branchName = sprint/<runId>/<stage> ← internal temp branch, NEVER on remote
|
|
1793
|
+
*
|
|
1794
|
+
* Merge gate must NEVER use worktree.branchName — it does not exist on the remote.
|
|
1795
|
+
* If spec.branch is not set, merge gate compares against 'main' (safe default).
|
|
1796
|
+
*
|
|
1797
|
+
* Returns { localHeadSha, remoteHeadSha, shaMatch, targetBranch }.
|
|
1798
|
+
* Writes result to stages/<stage>/merge-gate.json.
|
|
1799
|
+
*/
|
|
1800
|
+
function runMergeGateCheck({ runDir, state, spec, mergeGatePath }) {
|
|
1801
|
+
const workspace = state.worktree?.worktreePath ?? spec.workspace;
|
|
1802
|
+
const remote = 'origin';
|
|
1803
|
+
|
|
1804
|
+
// targetBranch is the REAL PR branch on remote — NEVER the internal sprint worktree branch
|
|
1805
|
+
const targetBranch = spec.branch ?? 'main';
|
|
1806
|
+
const remoteRef = `refs/remotes/${remote}/${targetBranch}`;
|
|
1807
|
+
|
|
1808
|
+
try {
|
|
1809
|
+
// Fetch the specific remote branch via targeted refspec
|
|
1810
|
+
const fetchResult = spawnSync('git', ['fetch', remote, `refs/heads/${targetBranch}:refs/remotes/${remote}/${targetBranch}`], {
|
|
1811
|
+
cwd: workspace,
|
|
1812
|
+
encoding: 'utf8',
|
|
1813
|
+
timeout: 60_000,
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
// If fetch fails (branch doesn't exist on remote), record clearly
|
|
1817
|
+
if (fetchResult.status !== 0) {
|
|
1818
|
+
const fetchErr = fetchResult.stderr?.trim() || fetchResult.stdout?.trim() || 'unknown';
|
|
1819
|
+
appendTimeline(runDir, `Merge gate: target branch '${targetBranch}' does not exist on remote: ${fetchErr}`);
|
|
1820
|
+
const result = { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: `Branch '${targetBranch}' not found on remote. Has it been pushed?`, fetchFailed: true };
|
|
1821
|
+
writeJson(mergeGatePath, result);
|
|
1822
|
+
return result;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const localShaResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
1826
|
+
cwd: workspace, encoding: 'utf8', timeout: 10_000,
|
|
1827
|
+
});
|
|
1828
|
+
if (localShaResult.status !== 0) {
|
|
1829
|
+
const errMsg = localShaResult.stderr?.trim() || localShaResult.stdout?.trim() || 'unknown';
|
|
1830
|
+
appendTimeline(runDir, `Merge gate: git rev-parse HEAD failed with status ${localShaResult.status}: ${errMsg}`);
|
|
1831
|
+
return { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: `git rev-parse HEAD failed: ${errMsg}` };
|
|
1832
|
+
}
|
|
1833
|
+
const localSha = (localShaResult.stdout ?? '').trim();
|
|
1834
|
+
|
|
1835
|
+
const remoteShaResult = spawnSync('git', ['rev-parse', remoteRef], {
|
|
1836
|
+
cwd: workspace, encoding: 'utf8', timeout: 10_000,
|
|
1837
|
+
});
|
|
1838
|
+
if (remoteShaResult.status !== 0) {
|
|
1839
|
+
appendTimeline(runDir, `Merge gate: remote ref ${remoteRef} not found after fetch (git status ${remoteShaResult.status})`);
|
|
1840
|
+
return { localHeadSha: localSha, remoteHeadSha: null, shaMatch: false, targetBranch, error: `Remote ref '${remoteRef}' not found after fetch` };
|
|
1841
|
+
}
|
|
1842
|
+
const remoteSha = (remoteShaResult.stdout ?? '').trim();
|
|
1843
|
+
|
|
1844
|
+
const shaMatch = localSha === remoteSha;
|
|
1845
|
+
|
|
1846
|
+
const result = { localHeadSha: localSha, remoteHeadSha: remoteSha, shaMatch, targetBranch };
|
|
1847
|
+
|
|
1848
|
+
writeJson(mergeGatePath, result);
|
|
1849
|
+
appendTimeline(runDir, `Merge gate: targetBranch=${targetBranch} local=${localSha.slice(0, 7)} remote=${remoteSha.slice(0, 7)} match=${shaMatch}`);
|
|
1850
|
+
|
|
1851
|
+
return result;
|
|
1852
|
+
} catch (gateErr) {
|
|
1853
|
+
appendTimeline(runDir, `Merge gate check failed: ${gateErr.message}`);
|
|
1854
|
+
return { localHeadSha: null, remoteHeadSha: null, shaMatch: false, targetBranch, error: gateErr.message };
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
function advanceState(state, spec, decision, { runDir } = {}) {
|
|
1859
|
+
if (decision.outcome === 'advance') {
|
|
1860
|
+
if (state.currentStageIndex >= spec.stages.length - 1) {
|
|
1861
|
+
// Merge gate check before completing final stage
|
|
1862
|
+
if (runDir) {
|
|
1863
|
+
const finalStageDir = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
|
|
1864
|
+
const mergeGatePath = path.join(runDir, 'stages', finalStageDir, 'merge-gate.json');
|
|
1865
|
+
const mergeGate = runMergeGateCheck({ runDir, state, spec, mergeGatePath });
|
|
1866
|
+
if (!mergeGate.shaMatch) {
|
|
1867
|
+
const fetchFailed = Boolean(mergeGate.fetchFailed);
|
|
1868
|
+
if (fetchFailed) {
|
|
1869
|
+
state.status = 'completed';
|
|
1870
|
+
state.mergePending = {
|
|
1871
|
+
targetBranch: mergeGate.targetBranch,
|
|
1872
|
+
reason: mergeGate.error,
|
|
1873
|
+
mergeGate,
|
|
1874
|
+
updatedAt: nowIso(),
|
|
1875
|
+
};
|
|
1876
|
+
state.haltReason = null;
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
state.status = 'halted';
|
|
1880
|
+
state.haltReason = {
|
|
1881
|
+
type: 'merge_gate_sha_mismatch',
|
|
1882
|
+
stage: state.currentStage,
|
|
1883
|
+
round: state.currentRound,
|
|
1884
|
+
targetBranch: mergeGate.targetBranch,
|
|
1885
|
+
details: `Merge gate failed: local SHA ${mergeGate.localHeadSha?.slice(0, 7) ?? '?'} != remote/${mergeGate.targetBranch} SHA ${mergeGate.remoteHeadSha?.slice(0, 7) ?? '?'}. Push or rebase before completing.`,
|
|
1886
|
+
blockers: ['Local SHA does not match remote target branch head. Push or rebase before completing.'],
|
|
1887
|
+
mergeGate,
|
|
1888
|
+
};
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
state.mergePending = null;
|
|
1893
|
+
state.status = 'completed';
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
state.currentStageIndex += 1;
|
|
1897
|
+
state.currentStage = spec.stages[state.currentStageIndex];
|
|
1898
|
+
state.currentWorkUnitIndex = 0;
|
|
1899
|
+
state.currentRound = 1;
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (decision.outcome === 'revise') {
|
|
1904
|
+
// Route implement-pass-1 revise → implement-pass-2 automatically
|
|
1905
|
+
if (state.currentStage === 'implement-pass-1') {
|
|
1906
|
+
state.currentStage = 'implement-pass-2';
|
|
1907
|
+
state.currentStageIndex = spec.stages.indexOf('implement-pass-2');
|
|
1908
|
+
state.currentWorkUnitIndex = 0;
|
|
1909
|
+
state.currentRound = 1;
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
state.currentRound += 1;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// Both 'halt' and 'error' outcomes halt the sprint
|
|
1917
|
+
// WF-003 fix: schema violations have already been retried in execStage via
|
|
1918
|
+
// the 'revise' outcome path. If we reach here with 'error', it means retries
|
|
1919
|
+
// were exhausted or the error is from the standard decision engine.
|
|
1920
|
+
// Schema violation errors are now returned with a special flag to trigger retry
|
|
1921
|
+
// instead of halt. See the schema validation block in decideAndPersist().
|
|
1922
|
+
state.status = 'halted';
|
|
1923
|
+
state.haltReason = {
|
|
1924
|
+
type: decision.outcome === 'error' ? 'stage_error' : 'max_rounds_exceeded',
|
|
1925
|
+
stage: state.currentStage,
|
|
1926
|
+
round: state.currentRound,
|
|
1927
|
+
details: decision.summary,
|
|
1928
|
+
blockers: decision.blockers,
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function maybeAbort(runDir, state) {
|
|
1933
|
+
const fresh = readJson(path.join(runDir, 'sprint.json'));
|
|
1934
|
+
if (fresh.status === 'aborted' || fresh.status === 'paused') {
|
|
1935
|
+
Object.assign(state, fresh);
|
|
1936
|
+
throw new Error(`Sprint ${fresh.status} by operator.`);
|
|
1937
|
+
}
|
|
1938
|
+
Object.assign(state, fresh);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function pidExists(pid) {
|
|
1942
|
+
if (!pid) return false;
|
|
1943
|
+
try {
|
|
1944
|
+
process.kill(pid, 0);
|
|
1945
|
+
return true;
|
|
1946
|
+
} catch {
|
|
1947
|
+
return false;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function reconcileRunState(runDir, state) {
|
|
1952
|
+
if (state.status !== 'running') return state;
|
|
1953
|
+
if (pidExists(state.orchestratorPid)) return state;
|
|
1954
|
+
|
|
1955
|
+
// Clean up any orphaned acpx queue-owner daemons and agent processes
|
|
1956
|
+
// from the previous orchestrator run. acpx queue-owners are detached
|
|
1957
|
+
// processes that may outlive their parent acpx invocation.
|
|
1958
|
+
cleanupAcpxOrphans(runDir, state);
|
|
1959
|
+
|
|
1960
|
+
// Detect which artifacts already exist on disk for the current stage/round
|
|
1961
|
+
const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
|
|
1962
|
+
const stageDir = path.join(runDir, 'stages', stageDirName);
|
|
1963
|
+
const artifacts = {
|
|
1964
|
+
producer: reportExistsAndNonEmpty(path.join(stageDir, 'producer.md')),
|
|
1965
|
+
reviewerA: reportExistsAndNonEmpty(path.join(stageDir, 'reviewer-a.md')),
|
|
1966
|
+
reviewerB: reportExistsAndNonEmpty(path.join(stageDir, 'reviewer-b.md')),
|
|
1967
|
+
globalReviewer: reportExistsAndNonEmpty(path.join(stageDir, 'global-reviewer.md')),
|
|
1968
|
+
decision: reportExistsAndNonEmpty(path.join(stageDir, 'decision.md')),
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
const roleLabel = state.currentRole ?? 'unknown';
|
|
1972
|
+
const diskSummary = Object.entries(artifacts)
|
|
1973
|
+
.filter(([, v]) => v)
|
|
1974
|
+
.map(([k]) => k)
|
|
1975
|
+
.join(', ') || 'none';
|
|
1976
|
+
|
|
1977
|
+
state.status = 'halted';
|
|
1978
|
+
state.haltReason = {
|
|
1979
|
+
type: 'stale_orchestrator',
|
|
1980
|
+
stage: state.currentStage,
|
|
1981
|
+
round: state.currentRound,
|
|
1982
|
+
details: `Orchestrator pid ${state.orchestratorPid ?? 'unknown'} is no longer alive (was: ${roleLabel}). Artifacts on disk: ${diskSummary}.`,
|
|
1983
|
+
blockers: ['The sprint process ended before stage completion. Resume to continue.'],
|
|
1984
|
+
artifacts,
|
|
1985
|
+
};
|
|
1986
|
+
saveState(runDir, state);
|
|
1987
|
+
updateSummary(runDir, [
|
|
1988
|
+
`Status: ${state.status}`,
|
|
1989
|
+
`Stage: ${state.currentStage}`,
|
|
1990
|
+
`Round: ${state.currentRound}`,
|
|
1991
|
+
`Halt reason: ${state.haltReason.details}`,
|
|
1992
|
+
`Disk artifacts: ${diskSummary}`,
|
|
1993
|
+
]);
|
|
1994
|
+
appendTimeline(runDir, `Sprint reconciled to halted: ${state.haltReason.details}`);
|
|
1995
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
1996
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
1997
|
+
cleanupWorktree({ state, runDir });
|
|
1998
|
+
return state;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* Clean up orphaned acpx queue-owner daemons and agent processes.
|
|
2003
|
+
* acpx spawns detached queue-owner processes that may outlive the acpx CLI
|
|
2004
|
+
* that created them. On stale-run reconciliation, these should be cleaned up.
|
|
2005
|
+
*/
|
|
2006
|
+
function cleanupAcpxOrphans(runDir, state) {
|
|
2007
|
+
// Derive workspace from known-good sources only:
|
|
2008
|
+
// 1. worktree (most reliable — we created it)
|
|
2009
|
+
// 2. spec.workspace (loaded fresh, has clear semantic meaning)
|
|
2010
|
+
const spec = state.taskId ? (() => {
|
|
2011
|
+
try { return getTaskSpec(state.taskId, state.specPath); } catch { return null; }
|
|
2012
|
+
})() : null;
|
|
2013
|
+
|
|
2014
|
+
const workspace = state.worktree?.worktreePath
|
|
2015
|
+
?? (spec?.workspace && spec.workspace !== state.specPath ? spec.workspace : null);
|
|
2016
|
+
|
|
2017
|
+
if (!workspace) {
|
|
2018
|
+
appendTimeline(runDir, 'acpx session cleanup skipped: no trusted workspace available');
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// Try to close any active acpx sessions for the workspace+agent combo
|
|
2023
|
+
const agent = spec?.producer?.agent ?? null;
|
|
2024
|
+
|
|
2025
|
+
if (agent) {
|
|
2026
|
+
try {
|
|
2027
|
+
// acpx <agent> sessions close — this removes the queue-owner lease
|
|
2028
|
+
// Use nodeBin + [acpxBin, ...] for consistency with runAgent spawn pattern
|
|
2029
|
+
const closeResult = spawnSync(nodeBin, [acpxBin, agent, 'sessions', 'close'],
|
|
2030
|
+
{ cwd: workspace, encoding: 'utf8', timeout: 10_000, env: acpxEnv, shell: false });
|
|
2031
|
+
if (closeResult.status === 0) {
|
|
2032
|
+
appendTimeline(runDir, `Cleaned up acpx ${agent} sessions for stale run recovery`);
|
|
2033
|
+
}
|
|
2034
|
+
} catch (closeErr) {
|
|
2035
|
+
// Non-fatal — queue-owner may already be gone
|
|
2036
|
+
appendTimeline(runDir, `acpx session cleanup skipped: ${closeErr.message}`);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Execute a single reviewer role asynchronously with independent timeout tracking.
|
|
2043
|
+
* Returns a result object: { role, output, timedOut, reportExisted, violatedFile }
|
|
2044
|
+
*/
|
|
2045
|
+
async function runReviewerRole({ runDir, state, spec, paths, role, worktreeInfo }) {
|
|
2046
|
+
const stageName = state.currentStage;
|
|
2047
|
+
const workUnit = getActiveWorkUnit(spec, stageName, {
|
|
2048
|
+
workUnitIndex: state.currentWorkUnitIndex ?? 0,
|
|
2049
|
+
});
|
|
2050
|
+
const config = roleConfig(spec, role);
|
|
2051
|
+
const { reportPath, stdoutPath } = roleArtifactPaths(paths, role);
|
|
2052
|
+
const failLogBase = path.join(paths.stageDir, `${role.replace('_', '-')}-failure`);
|
|
2053
|
+
|
|
2054
|
+
// Skip if report already exists
|
|
2055
|
+
if (fileExists(reportPath) && fs.readFileSync(reportPath, 'utf8').trim()) {
|
|
2056
|
+
updateRoleState(paths, role, {
|
|
2057
|
+
stage: stageName,
|
|
2058
|
+
round: state.currentRound,
|
|
2059
|
+
status: 'completed',
|
|
2060
|
+
lastPid: null,
|
|
2061
|
+
finishedAt: nowIso(),
|
|
2062
|
+
timeoutSeconds: stageRoleTimeout(spec, stageName, role),
|
|
2063
|
+
lastError: null,
|
|
2064
|
+
});
|
|
2065
|
+
return { role, output: fs.readFileSync(reportPath, 'utf8').trim(), timedOut: false, reportExisted: true, violatedFile: null };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const protectedFiles = protectedArtifacts(runDir, paths);
|
|
2069
|
+
const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
|
|
2070
|
+
const prompt = buildRolePrompt({
|
|
2071
|
+
spec,
|
|
2072
|
+
stage: stageName,
|
|
2073
|
+
round: state.currentRound,
|
|
2074
|
+
role,
|
|
2075
|
+
runDir,
|
|
2076
|
+
stageDir: paths.stageDir,
|
|
2077
|
+
briefPath: paths.briefPath,
|
|
2078
|
+
producerPath: paths.producerPath,
|
|
2079
|
+
reviewerAPath: paths.reviewerAPath,
|
|
2080
|
+
reviewerBPath: paths.reviewerBPath,
|
|
2081
|
+
globalReviewerPath: paths.globalReviewerPath,
|
|
2082
|
+
workUnit,
|
|
2083
|
+
});
|
|
2084
|
+
const timeoutSeconds = stageRoleTimeout(spec, stageName, role);
|
|
2085
|
+
// Reviewers must verify code in the same workspace where producer made changes
|
|
2086
|
+
// branchWorkspace may not exist — fall back to spec.workspace (always valid)
|
|
2087
|
+
const cwd = worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace);
|
|
2088
|
+
|
|
2089
|
+
let output = null;
|
|
2090
|
+
let timedOut = false;
|
|
2091
|
+
let violatedFile = null;
|
|
2092
|
+
let reportExisted = false;
|
|
2093
|
+
const attemptErrors = [];
|
|
2094
|
+
const attempts = roleAttemptConfigs(config);
|
|
2095
|
+
|
|
2096
|
+
for (let index = 0; index < attempts.length; index += 1) {
|
|
2097
|
+
const attemptConfig = attempts[index];
|
|
2098
|
+
const attemptLabel = attemptConfig.attemptLabel ?? `attempt_${index + 1}`;
|
|
2099
|
+
const attemptTimeoutSeconds = stageRoleTimeout(
|
|
2100
|
+
{
|
|
2101
|
+
...spec,
|
|
2102
|
+
reviewerA: role === 'reviewer_a' ? attemptConfig : spec.reviewerA,
|
|
2103
|
+
reviewerB: role === 'reviewer_b' ? attemptConfig : spec.reviewerB,
|
|
2104
|
+
},
|
|
2105
|
+
stageName,
|
|
2106
|
+
role
|
|
2107
|
+
);
|
|
2108
|
+
const failLog = `${failLogBase}-${attemptLabel}.log`;
|
|
2109
|
+
|
|
2110
|
+
updateRoleState(paths, role, {
|
|
2111
|
+
stage: stageName,
|
|
2112
|
+
round: state.currentRound,
|
|
2113
|
+
status: index === 0 ? 'running' : 'retrying',
|
|
2114
|
+
startedAt: nowIso(),
|
|
2115
|
+
finishedAt: null,
|
|
2116
|
+
terminatedAt: null,
|
|
2117
|
+
lastPid: null,
|
|
2118
|
+
timeoutSeconds: attemptTimeoutSeconds,
|
|
2119
|
+
lastError: null,
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
if (index > 0) {
|
|
2123
|
+
appendTimeline(
|
|
2124
|
+
runDir,
|
|
2125
|
+
`${role} retrying with ${attemptConfig.agent}/${attemptConfig.model} (${attemptLabel})`
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
try {
|
|
2130
|
+
const result = await runAgentAsync({
|
|
2131
|
+
cwd,
|
|
2132
|
+
agent: attemptConfig.agent,
|
|
2133
|
+
model: attemptConfig.model,
|
|
2134
|
+
prompt,
|
|
2135
|
+
timeoutSeconds: attemptTimeoutSeconds,
|
|
2136
|
+
promptDir: runDir,
|
|
2137
|
+
runDir,
|
|
2138
|
+
roleLabel: role,
|
|
2139
|
+
onSpawn: (pid) => updateRoleState(paths, role, { lastPid: pid }),
|
|
2140
|
+
});
|
|
2141
|
+
const stdout = result.stdout ?? '';
|
|
2142
|
+
const stderr = result.stderr ?? '';
|
|
2143
|
+
|
|
2144
|
+
if (result.status !== 0) {
|
|
2145
|
+
ensureDir(path.dirname(failLog));
|
|
2146
|
+
writeText(
|
|
2147
|
+
failLog,
|
|
2148
|
+
`# Agent Failure Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- exitStatus: ${result.status}\n\n## stdout\n\n${stdout.slice(0, 2000)}\n\n## stderr\n\n${stderr.slice(0, 2000)}\n`
|
|
2149
|
+
);
|
|
2150
|
+
throw new Error(`Agent ${attemptConfig.agent} failed with status ${result.status}`);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
output = stdout.trim();
|
|
2154
|
+
if (output) writeText(stdoutPath, `${output}\n`);
|
|
2155
|
+
updateRoleState(paths, role, {
|
|
2156
|
+
status: 'completed',
|
|
2157
|
+
lastPid: null,
|
|
2158
|
+
finishedAt: nowIso(),
|
|
2159
|
+
lastError: null,
|
|
2160
|
+
});
|
|
2161
|
+
break;
|
|
2162
|
+
} catch (err) {
|
|
2163
|
+
const errorMessage = err.message ?? String(err);
|
|
2164
|
+
const hasReport = fileExists(reportPath) && fs.readFileSync(reportPath, 'utf8').trim();
|
|
2165
|
+
|
|
2166
|
+
if (hasReport) {
|
|
2167
|
+
output = null;
|
|
2168
|
+
timedOut = errorMessage.includes('timed out');
|
|
2169
|
+
reportExisted = true;
|
|
2170
|
+
updateRoleState(paths, role, {
|
|
2171
|
+
status: 'completed_after_timeout',
|
|
2172
|
+
lastPid: null,
|
|
2173
|
+
finishedAt: nowIso(),
|
|
2174
|
+
lastError: errorMessage,
|
|
2175
|
+
});
|
|
2176
|
+
break;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
const isTimedOut = errorMessage.includes('timed out');
|
|
2180
|
+
attemptErrors.push({
|
|
2181
|
+
label: attemptLabel,
|
|
2182
|
+
agent: attemptConfig.agent,
|
|
2183
|
+
model: attemptConfig.model,
|
|
2184
|
+
error: errorMessage,
|
|
2185
|
+
timedOut: isTimedOut,
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
ensureDir(path.dirname(failLog));
|
|
2189
|
+
if (isTimedOut) {
|
|
2190
|
+
timedOut = true;
|
|
2191
|
+
writeText(
|
|
2192
|
+
failLog,
|
|
2193
|
+
`# Agent Timeout Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- timeout: ${attemptTimeoutSeconds}s\n- error: ${errorMessage}\n`
|
|
2194
|
+
);
|
|
2195
|
+
updateRoleState(paths, role, {
|
|
2196
|
+
status: 'timed_out',
|
|
2197
|
+
lastPid: null,
|
|
2198
|
+
finishedAt: nowIso(),
|
|
2199
|
+
terminatedAt: nowIso(),
|
|
2200
|
+
lastError: errorMessage,
|
|
2201
|
+
});
|
|
2202
|
+
} else {
|
|
2203
|
+
writeText(
|
|
2204
|
+
failLog,
|
|
2205
|
+
`# Agent Failure Log\n\n- attempt: ${attemptLabel}\n- agent: ${attemptConfig.agent}\n- model: ${attemptConfig.model}\n- error: ${errorMessage}\n`
|
|
2206
|
+
);
|
|
2207
|
+
updateRoleState(paths, role, {
|
|
2208
|
+
status: 'error',
|
|
2209
|
+
lastPid: null,
|
|
2210
|
+
finishedAt: nowIso(),
|
|
2211
|
+
lastError: errorMessage,
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
if (index < attempts.length - 1) {
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (!isTimedOut) {
|
|
2220
|
+
throw new Error(
|
|
2221
|
+
`${role} failed after ${attemptErrors.length} attempt(s): ${attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}: ${item.error}`).join(' | ')}`
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
// Collect isolation artifacts using runId for unique isolation directory
|
|
2228
|
+
const reportFilename = path.basename(reportPath);
|
|
2229
|
+
const { collected, isolationReportPath } = collectIsolationArtifacts({
|
|
2230
|
+
runId: state.runId,
|
|
2231
|
+
stageName,
|
|
2232
|
+
role,
|
|
2233
|
+
stageDir: paths.stageDir,
|
|
2234
|
+
reportFilename,
|
|
2235
|
+
runDir,
|
|
2236
|
+
});
|
|
2237
|
+
|
|
2238
|
+
const text = readRoleOutput({ reportPath, stdout: output ?? '', isolationReportPath });
|
|
2239
|
+
if (!text && output) {
|
|
2240
|
+
writeText(reportPath, `${output}\n`);
|
|
2241
|
+
} else if (text) {
|
|
2242
|
+
if (!fileExists(reportPath)) writeText(reportPath, `${text}\n`);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
const violated = detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
|
|
2246
|
+
if (violated) {
|
|
2247
|
+
violatedFile = violated.file;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
return { role, output: text || output || null, timedOut, reportExisted, violatedFile, attemptErrors };
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
const MUTATING_STAGES = ['implement-pass-1', 'implement-pass-2'];
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* Ensure a git worktree exists for the given mutating stage.
|
|
2257
|
+
* Reuses existing worktree if already present. Returns null if not applicable.
|
|
2258
|
+
*
|
|
2259
|
+
* Uses a legal git worktree add: git worktree add -b <newBranch> <worktreePath> <baseRef>
|
|
2260
|
+
* where baseRef is a real git ref (branch/tag/SHA), not a filesystem path.
|
|
2261
|
+
*/
|
|
2262
|
+
function ensureWorktree({ spec, runDir, state, stageName }) {
|
|
2263
|
+
if (!MUTATING_STAGES.includes(stageName)) return null;
|
|
2264
|
+
|
|
2265
|
+
const baseWorkspace = spec.workspace;
|
|
2266
|
+
const baseBranch = spec.branch ?? 'main';
|
|
2267
|
+
const branchName = `sprint/${state.runId.slice(0, 25)}/${stageName}`;
|
|
2268
|
+
const worktreePath = path.join(runDir, 'worktrees', stageName);
|
|
2269
|
+
|
|
2270
|
+
// Resolve baseRef with robust fallback chain:
|
|
2271
|
+
// 1. spec.branch (local branch)
|
|
2272
|
+
// 2. origin/{spec.branch} (remote branch)
|
|
2273
|
+
// 3. HEAD (current checkout)
|
|
2274
|
+
// 4. main (default branch)
|
|
2275
|
+
const resolveBaseRef = () => {
|
|
2276
|
+
const candidates = [
|
|
2277
|
+
{ ref: baseBranch, label: `spec.branch '${baseBranch}'` },
|
|
2278
|
+
{ ref: `origin/${baseBranch}`, label: `remote branch 'origin/${baseBranch}'` },
|
|
2279
|
+
{ ref: 'HEAD', label: 'current HEAD' },
|
|
2280
|
+
{ ref: 'main', label: 'default branch main' },
|
|
2281
|
+
];
|
|
2282
|
+
|
|
2283
|
+
for (const candidate of candidates) {
|
|
2284
|
+
const result = spawnSync('git', ['rev-parse', '--verify', candidate.ref], {
|
|
2285
|
+
cwd: baseWorkspace,
|
|
2286
|
+
encoding: 'utf8',
|
|
2287
|
+
timeout: 10_000,
|
|
2288
|
+
});
|
|
2289
|
+
if (result.status === 0 && result.stdout?.trim()) {
|
|
2290
|
+
if (candidate.ref !== baseBranch) {
|
|
2291
|
+
appendTimeline(runDir, `Base ref resolved to ${candidate.label}`);
|
|
2292
|
+
}
|
|
2293
|
+
return candidate.ref;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// Last resort: return 'main' even if verification failed
|
|
2297
|
+
appendTimeline(runDir, `Warning: Could not verify any base ref, using 'main'`);
|
|
2298
|
+
return 'main';
|
|
2299
|
+
};
|
|
2300
|
+
|
|
2301
|
+
const baseRef = resolveBaseRef();
|
|
2302
|
+
|
|
2303
|
+
// Reuse existing worktree if already created for this stage
|
|
2304
|
+
if (state.worktree?.worktreePath && fileExists(state.worktree.worktreePath)) {
|
|
2305
|
+
appendTimeline(runDir, `Reusing existing worktree at ${state.worktree.worktreePath}`);
|
|
2306
|
+
return state.worktree;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Also check if worktree directory exists on disk (state may have been lost)
|
|
2310
|
+
// This handles resume scenarios where sprint.json lost worktree state
|
|
2311
|
+
if (fileExists(worktreePath)) {
|
|
2312
|
+
// Verify it's a valid worktree by checking for .git file
|
|
2313
|
+
const gitFile = path.join(worktreePath, '.git');
|
|
2314
|
+
if (fileExists(gitFile)) {
|
|
2315
|
+
const headSha = (spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
2316
|
+
cwd: worktreePath,
|
|
2317
|
+
encoding: 'utf8',
|
|
2318
|
+
timeout: 10_000,
|
|
2319
|
+
}).stdout ?? '').trim();
|
|
2320
|
+
|
|
2321
|
+
if (headSha) {
|
|
2322
|
+
const recoveredWorktree = {
|
|
2323
|
+
worktreePath,
|
|
2324
|
+
branchName,
|
|
2325
|
+
headSha,
|
|
2326
|
+
baseBranch,
|
|
2327
|
+
baseWorkspace,
|
|
2328
|
+
dirtyFiles: [],
|
|
2329
|
+
};
|
|
2330
|
+
state.worktree = recoveredWorktree;
|
|
2331
|
+
appendTimeline(runDir, `Recovered existing worktree at ${worktreePath} (branch: ${branchName}, sha: ${headSha})`);
|
|
2332
|
+
return recoveredWorktree;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
// Invalid worktree directory - remove it
|
|
2336
|
+
appendTimeline(runDir, `Removing invalid worktree directory at ${worktreePath}`);
|
|
2337
|
+
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
try {
|
|
2341
|
+
ensureDir(path.dirname(worktreePath));
|
|
2342
|
+
|
|
2343
|
+
// Create a new worktree with a new branch based on baseRef (a real git ref)
|
|
2344
|
+
// Syntax: git worktree add -b <newBranch> <path> <startPoint>
|
|
2345
|
+
// where <startPoint> is baseRef (e.g., 'main' or 'origin/main')
|
|
2346
|
+
const result = spawnSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
|
|
2347
|
+
cwd: baseWorkspace,
|
|
2348
|
+
encoding: 'utf8',
|
|
2349
|
+
timeout: 30_000,
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
if (result.status !== 0) {
|
|
2353
|
+
const errMsg = result.stderr?.trim() || result.stdout?.trim() || 'unknown error';
|
|
2354
|
+
appendTimeline(runDir, `Worktree creation failed: ${errMsg} — falling back to base workspace`);
|
|
2355
|
+
return null;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
const headSha = (spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
2359
|
+
cwd: worktreePath,
|
|
2360
|
+
encoding: 'utf8',
|
|
2361
|
+
timeout: 10_000,
|
|
2362
|
+
}).stdout ?? '').trim();
|
|
2363
|
+
|
|
2364
|
+
const worktreeInfo = {
|
|
2365
|
+
worktreePath,
|
|
2366
|
+
branchName,
|
|
2367
|
+
headSha,
|
|
2368
|
+
baseBranch,
|
|
2369
|
+
baseWorkspace, // Store for cleanup cwd
|
|
2370
|
+
dirtyFiles: [],
|
|
2371
|
+
};
|
|
2372
|
+
|
|
2373
|
+
state.worktree = worktreeInfo;
|
|
2374
|
+
appendTimeline(runDir, `Created worktree at ${worktreePath} (branch: ${branchName}, baseRef: ${baseRef}, sha: ${headSha})`);
|
|
2375
|
+
return worktreeInfo;
|
|
2376
|
+
} catch (wtErr) {
|
|
2377
|
+
appendTimeline(runDir, `Worktree creation failed: ${wtErr.message} — falling back to base workspace`);
|
|
2378
|
+
return null;
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/**
|
|
2383
|
+
* Capture git status for the worktree and write git-status.json to stageDir.
|
|
2384
|
+
*/
|
|
2385
|
+
function captureGitStatus({ worktreePath, runDir, stageDir, state }) {
|
|
2386
|
+
if (!worktreePath || !fileExists(worktreePath)) return null;
|
|
2387
|
+
try {
|
|
2388
|
+
const statusResult = spawnSync('git', ['status', '--short'], {
|
|
2389
|
+
cwd: worktreePath,
|
|
2390
|
+
encoding: 'utf8',
|
|
2391
|
+
timeout: 10_000,
|
|
2392
|
+
});
|
|
2393
|
+
const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
2394
|
+
cwd: worktreePath,
|
|
2395
|
+
encoding: 'utf8',
|
|
2396
|
+
timeout: 10_000,
|
|
2397
|
+
});
|
|
2398
|
+
const shaResult = spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
2399
|
+
cwd: worktreePath,
|
|
2400
|
+
encoding: 'utf8',
|
|
2401
|
+
timeout: 10_000,
|
|
2402
|
+
});
|
|
2403
|
+
const dirtyFiles = ((statusResult.stdout ?? '').trim().split('\n').filter(Boolean) || []);
|
|
2404
|
+
|
|
2405
|
+
const gitStatus = {
|
|
2406
|
+
branch: (branchResult.stdout ?? '').trim(),
|
|
2407
|
+
headSha: (shaResult.stdout ?? '').trim(),
|
|
2408
|
+
worktreePath,
|
|
2409
|
+
dirtyFiles,
|
|
2410
|
+
baseBranch: state.worktree?.baseBranch ?? null,
|
|
2411
|
+
remoteBranch: state.worktree?.branchName ? `origin/${state.worktree.branchName}` : null,
|
|
2412
|
+
};
|
|
2413
|
+
|
|
2414
|
+
const gitStatusPath = path.join(stageDir, 'git-status.json');
|
|
2415
|
+
writeJson(gitStatusPath, gitStatus);
|
|
2416
|
+
appendTimeline(runDir, `Git status: ${dirtyFiles.length} dirty files, sha: ${gitStatus.headSha}`);
|
|
2417
|
+
return gitStatus;
|
|
2418
|
+
} catch (err) {
|
|
2419
|
+
appendTimeline(runDir, `Git status capture failed: ${err.message}`);
|
|
2420
|
+
return null;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* Remove the worktree from the filesystem (on halt).
|
|
2426
|
+
* Uses the stored baseWorkspace as cwd (the git repo root), not the worktree's parent dir.
|
|
2427
|
+
*/
|
|
2428
|
+
function cleanupWorktree({ state, runDir }) {
|
|
2429
|
+
if (!state.worktree?.worktreePath) return;
|
|
2430
|
+
const { worktreePath, baseWorkspace } = state.worktree;
|
|
2431
|
+
// Use the git repo root (baseWorkspace) as cwd, not the worktree's parent dir
|
|
2432
|
+
const gitCwd = baseWorkspace ?? runDir;
|
|
2433
|
+
try {
|
|
2434
|
+
const result = spawnSync('git', ['worktree', 'remove', '--force', worktreePath], {
|
|
2435
|
+
cwd: gitCwd,
|
|
2436
|
+
encoding: 'utf8',
|
|
2437
|
+
timeout: 30_000,
|
|
2438
|
+
});
|
|
2439
|
+
if (result.status === 0) {
|
|
2440
|
+
appendTimeline(runDir, `Cleaned up worktree at ${worktreePath}`);
|
|
2441
|
+
state.worktree = null;
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
const errMsg = result.stderr?.trim() || result.stdout?.trim() || `git exited ${result.status}`;
|
|
2445
|
+
appendTimeline(runDir, `Worktree cleanup failed for ${worktreePath}: ${errMsg} — manual cleanup may be required`);
|
|
2446
|
+
} catch (cleanupErr) {
|
|
2447
|
+
appendTimeline(runDir, `Worktree cleanup failed for ${worktreePath}: ${cleanupErr.message} — manual cleanup may be required`);
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
async function executeStage(runDir, state, spec) {
|
|
2452
|
+
const stageName = state.currentStage;
|
|
2453
|
+
const workUnit = getActiveWorkUnit(spec, stageName, {
|
|
2454
|
+
workUnitIndex: state.currentWorkUnitIndex ?? 0,
|
|
2455
|
+
});
|
|
2456
|
+
const previousDecisionPath = path.join(
|
|
2457
|
+
runDir,
|
|
2458
|
+
'stages',
|
|
2459
|
+
`${String(state.currentStageIndex + 1).padStart(2, '0')}-${stageName}`,
|
|
2460
|
+
'decision.md',
|
|
2461
|
+
);
|
|
2462
|
+
const previousDecision = fileExists(previousDecisionPath)
|
|
2463
|
+
? fs.readFileSync(previousDecisionPath, 'utf8')
|
|
2464
|
+
: '';
|
|
2465
|
+
|
|
2466
|
+
const paths = ensureStagePaths(
|
|
2467
|
+
runDir,
|
|
2468
|
+
state.currentStageIndex,
|
|
2469
|
+
stageName,
|
|
2470
|
+
);
|
|
2471
|
+
const { stageDir, briefPath, producerPath, reviewerAPath, reviewerBPath, globalReviewerPath, decisionPath, scorecardPath } = paths;
|
|
2472
|
+
const protectedFiles = protectedArtifacts(runDir, paths);
|
|
2473
|
+
|
|
2474
|
+
// Read handoff data for structured carry forward
|
|
2475
|
+
const handoffPath = path.join(stageDir, 'handoff.json');
|
|
2476
|
+
const handoff = fileExists(handoffPath) ? readJson(handoffPath) : null;
|
|
2477
|
+
const checkpointSummaryPath = path.join(stageDir, 'checkpoint-summary.md');
|
|
2478
|
+
const checkpointSummary = fileExists(checkpointSummaryPath)
|
|
2479
|
+
? fs.readFileSync(checkpointSummaryPath, 'utf8')
|
|
2480
|
+
: null;
|
|
2481
|
+
|
|
2482
|
+
writeText(briefPath, `${buildStageBrief(spec, stageName, state.currentRound, previousDecision, handoff, checkpointSummary, {
|
|
2483
|
+
workUnitIndex: state.currentWorkUnitIndex ?? 0,
|
|
2484
|
+
runDir,
|
|
2485
|
+
stageDir,
|
|
2486
|
+
})}\n`);
|
|
2487
|
+
|
|
2488
|
+
// On round > 1, clear previous round's role reports so agents must regenerate them
|
|
2489
|
+
if (state.currentRound > 1) {
|
|
2490
|
+
const staleReports = [producerPath, reviewerAPath, reviewerBPath, globalReviewerPath];
|
|
2491
|
+
for (const fp of staleReports) {
|
|
2492
|
+
if (fileExists(fp)) {
|
|
2493
|
+
fs.unlinkSync(fp);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
appendTimeline(runDir, `Cleared previous round reports for stage ${stageName} round ${state.currentRound}`);
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const outputs = {};
|
|
2500
|
+
const stageStartedAt = Date.now();
|
|
2501
|
+
const stageTimeoutMs = (spec.stageTimeoutMinutes ?? 30) * 60_000;
|
|
2502
|
+
|
|
2503
|
+
// ── Phase 1: Producer (sequential) ────────────────────────────────────────
|
|
2504
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} started (producer)`);
|
|
2505
|
+
updateSummary(runDir, [
|
|
2506
|
+
`Status: ${state.status}`,
|
|
2507
|
+
`Stage: ${stageName}`,
|
|
2508
|
+
`Round: ${state.currentRound}`,
|
|
2509
|
+
'Producer is running.',
|
|
2510
|
+
]);
|
|
2511
|
+
|
|
2512
|
+
maybeAbort(runDir, state);
|
|
2513
|
+
heartbeatState(runDir, state, { currentRole: 'producer' });
|
|
2514
|
+
|
|
2515
|
+
// Per-stage runtime check before producer
|
|
2516
|
+
const stageElapsedProducer = Date.now() - stageStartedAt;
|
|
2517
|
+
if (stageElapsedProducer > stageTimeoutMs) {
|
|
2518
|
+
state.status = 'halted';
|
|
2519
|
+
state.haltReason = {
|
|
2520
|
+
type: 'stage_timeout',
|
|
2521
|
+
stage: stageName,
|
|
2522
|
+
round: state.currentRound,
|
|
2523
|
+
details: `Stage ${stageName} exceeded ${(stageTimeoutMs / 60_000).toFixed(0)} minutes before producer`,
|
|
2524
|
+
blockers: [`Stage timeout before producer started after ${(stageElapsedProducer / 60_000).toFixed(1)} minutes`],
|
|
2525
|
+
};
|
|
2526
|
+
saveState(runDir, state);
|
|
2527
|
+
appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
|
|
2528
|
+
writeStageFailureArtifacts({
|
|
2529
|
+
runDir,
|
|
2530
|
+
state,
|
|
2531
|
+
stageName,
|
|
2532
|
+
stageDir,
|
|
2533
|
+
decisionPath,
|
|
2534
|
+
scorecardPath,
|
|
2535
|
+
summary: state.haltReason.details,
|
|
2536
|
+
blockers: state.haltReason.blockers,
|
|
2537
|
+
});
|
|
2538
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
2539
|
+
return { outcome: 'halt', summary: state.haltReason.details, blockers: state.haltReason.blockers, metrics: {} };
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
const producerConfig = roleConfig(spec, 'producer');
|
|
2543
|
+
const producerTimeoutSeconds = stageRoleTimeout(spec, stageName, 'producer');
|
|
2544
|
+
const producerPrompt = buildRolePrompt({
|
|
2545
|
+
spec,
|
|
2546
|
+
stage: stageName,
|
|
2547
|
+
round: state.currentRound,
|
|
2548
|
+
role: 'producer',
|
|
2549
|
+
runDir,
|
|
2550
|
+
stageDir,
|
|
2551
|
+
briefPath,
|
|
2552
|
+
producerPath,
|
|
2553
|
+
reviewerAPath,
|
|
2554
|
+
reviewerBPath,
|
|
2555
|
+
workUnit,
|
|
2556
|
+
});
|
|
2557
|
+
const { reportPath: producerReportPath, stdoutPath: producerStdoutPath } = roleArtifactPaths(paths, 'producer');
|
|
2558
|
+
|
|
2559
|
+
// Worktree setup for mutating stages
|
|
2560
|
+
const worktreeInfo = ensureWorktree({ spec, runDir, state, stageName });
|
|
2561
|
+
// branchWorkspace may not exist — fall back to spec.workspace (always valid)
|
|
2562
|
+
const producerWorkspace = worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace);
|
|
2563
|
+
|
|
2564
|
+
if (fileExists(producerReportPath) && fs.readFileSync(producerReportPath, 'utf8').trim()) {
|
|
2565
|
+
outputs.producer = fs.readFileSync(producerReportPath, 'utf8').trim();
|
|
2566
|
+
updateRoleState(paths, 'producer', {
|
|
2567
|
+
stage: stageName,
|
|
2568
|
+
round: state.currentRound,
|
|
2569
|
+
status: 'completed',
|
|
2570
|
+
lastPid: null,
|
|
2571
|
+
finishedAt: nowIso(),
|
|
2572
|
+
timeoutSeconds: producerTimeoutSeconds,
|
|
2573
|
+
lastError: null,
|
|
2574
|
+
});
|
|
2575
|
+
appendTimeline(runDir, `producer skipped (report already exists) stage ${stageName} round ${state.currentRound}`);
|
|
2576
|
+
// Capture git status even for skipped producer (worktree may have changed)
|
|
2577
|
+
if (worktreeInfo?.worktreePath) {
|
|
2578
|
+
captureGitStatus({ worktreePath: worktreeInfo.worktreePath, runDir, stageDir, state });
|
|
2579
|
+
}
|
|
2580
|
+
} else {
|
|
2581
|
+
updateRoleState(paths, 'producer', {
|
|
2582
|
+
stage: stageName,
|
|
2583
|
+
round: state.currentRound,
|
|
2584
|
+
status: 'running',
|
|
2585
|
+
startedAt: nowIso(),
|
|
2586
|
+
finishedAt: null,
|
|
2587
|
+
terminatedAt: null,
|
|
2588
|
+
lastPid: null,
|
|
2589
|
+
timeoutSeconds: producerTimeoutSeconds,
|
|
2590
|
+
lastError: null,
|
|
2591
|
+
});
|
|
2592
|
+
const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
|
|
2593
|
+
const producerFailLog = path.join(stageDir, 'producer-failure.log');
|
|
2594
|
+
const producerWorklogPath = paths.producerWorklogPath;
|
|
2595
|
+
let producerOutput;
|
|
2596
|
+
let producerTimedOut = false;
|
|
2597
|
+
let extensionsUsed = 0;
|
|
2598
|
+
try {
|
|
2599
|
+
const result = await runAgentWithProgressCheck({
|
|
2600
|
+
cwd: producerWorkspace,
|
|
2601
|
+
agent: producerConfig.agent,
|
|
2602
|
+
model: producerConfig.model,
|
|
2603
|
+
prompt: producerPrompt,
|
|
2604
|
+
timeoutSeconds: producerTimeoutSeconds,
|
|
2605
|
+
promptDir: runDir,
|
|
2606
|
+
runDir,
|
|
2607
|
+
roleLabel: 'producer',
|
|
2608
|
+
worktreePath: worktreeInfo?.worktreePath,
|
|
2609
|
+
worklogPath: producerWorklogPath,
|
|
2610
|
+
});
|
|
2611
|
+
producerOutput = result.stdout;
|
|
2612
|
+
extensionsUsed = result.extensionsUsed ?? 0;
|
|
2613
|
+
if (extensionsUsed > 0) {
|
|
2614
|
+
appendTimeline(runDir, `Producer used ${extensionsUsed} timeout extensions`);
|
|
2615
|
+
}
|
|
2616
|
+
} catch (agentErr) {
|
|
2617
|
+
// Track consecutive timeouts for fuse mechanism
|
|
2618
|
+
const isTimeout = agentErr.message?.includes('timed out') || agentErr.message?.includes('ETIMEDOUT');
|
|
2619
|
+
if (isTimeout) {
|
|
2620
|
+
// Initialize consecutiveTimeouts if not present (resume from old state)
|
|
2621
|
+
if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
|
|
2622
|
+
state.consecutiveTimeouts[stageName] = (state.consecutiveTimeouts[stageName] || 0) + 1;
|
|
2623
|
+
appendTimeline(runDir, `Producer timeout #${state.consecutiveTimeouts[stageName]} for stage ${stageName}`);
|
|
2624
|
+
|
|
2625
|
+
// Fuse: halt after 2 consecutive timeouts on same stage
|
|
2626
|
+
if (state.consecutiveTimeouts[stageName] >= 2) {
|
|
2627
|
+
state.status = 'halted';
|
|
2628
|
+
state.haltReason = {
|
|
2629
|
+
type: 'repeated_timeout',
|
|
2630
|
+
stage: stageName,
|
|
2631
|
+
round: state.currentRound,
|
|
2632
|
+
details: `Producer timed out ${state.consecutiveTimeouts[stageName]} times on stage ${stageName}. Timeout may be too short or task too complex.`,
|
|
2633
|
+
blockers: [
|
|
2634
|
+
'Repeated timeout suggests insufficient timeout or overly complex task.',
|
|
2635
|
+
'Consider: 1) Increase timeout in stageRoleTimeouts, 2) Simplify task scope, 3) Check for infinite loops in agent.',
|
|
2636
|
+
],
|
|
2637
|
+
};
|
|
2638
|
+
saveState(runDir, state);
|
|
2639
|
+
appendTimeline(runDir, `Sprint halted: repeated timeout on stage ${stageName}`);
|
|
2640
|
+
writeStageFailureArtifacts({
|
|
2641
|
+
runDir,
|
|
2642
|
+
state,
|
|
2643
|
+
stageName,
|
|
2644
|
+
stageDir,
|
|
2645
|
+
decisionPath,
|
|
2646
|
+
scorecardPath,
|
|
2647
|
+
summary: state.haltReason.details,
|
|
2648
|
+
blockers: state.haltReason.blockers,
|
|
2649
|
+
});
|
|
2650
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
2651
|
+
cleanupWorktree({ state, runDir });
|
|
2652
|
+
throw new Error(state.haltReason.details);
|
|
2653
|
+
}
|
|
2654
|
+
} else {
|
|
2655
|
+
// Reset counter on non-timeout error
|
|
2656
|
+
if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
|
|
2657
|
+
state.consecutiveTimeouts[stageName] = 0;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
// Write failure log
|
|
2661
|
+
if (producerFailLog) {
|
|
2662
|
+
ensureDir(path.dirname(producerFailLog));
|
|
2663
|
+
writeText(producerFailLog, `# Agent Failure Log\n\n- agent: ${producerConfig.agent}\n- model: ${producerConfig.model}\n- error: ${agentErr.message ?? String(agentErr)}\n- extensionsUsed: ${extensionsUsed}\n\n`);
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
if (fileExists(producerReportPath) && fs.readFileSync(producerReportPath, 'utf8').trim()) {
|
|
2667
|
+
appendTimeline(runDir, `producer spawnSync timed out but report exists — recovering stage ${stageName} round ${state.currentRound}`);
|
|
2668
|
+
producerOutput = null;
|
|
2669
|
+
producerTimedOut = true;
|
|
2670
|
+
updateRoleState(paths, 'producer', {
|
|
2671
|
+
status: 'completed_after_timeout',
|
|
2672
|
+
lastPid: null,
|
|
2673
|
+
finishedAt: nowIso(),
|
|
2674
|
+
terminatedAt: nowIso(),
|
|
2675
|
+
lastError: agentErr.message ?? String(agentErr),
|
|
2676
|
+
});
|
|
2677
|
+
} else {
|
|
2678
|
+
updateRoleState(paths, 'producer', {
|
|
2679
|
+
status: isTimeout ? 'timed_out' : 'error',
|
|
2680
|
+
lastPid: null,
|
|
2681
|
+
finishedAt: nowIso(),
|
|
2682
|
+
terminatedAt: isTimeout ? nowIso() : null,
|
|
2683
|
+
lastError: agentErr.message ?? String(agentErr),
|
|
2684
|
+
});
|
|
2685
|
+
throw agentErr;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
if (producerOutput) writeText(producerStdoutPath, `${producerOutput}\n`);
|
|
2689
|
+
outputs.producer = readRoleOutput({ reportPath: producerReportPath, stdout: producerOutput ?? '' });
|
|
2690
|
+
if (!fileExists(producerReportPath) || !fs.readFileSync(producerReportPath, 'utf8').trim()) {
|
|
2691
|
+
writeText(producerReportPath, `${outputs.producer}\n`);
|
|
2692
|
+
}
|
|
2693
|
+
updateRoleState(paths, 'producer', {
|
|
2694
|
+
status: producerTimedOut ? 'completed_after_timeout' : 'completed',
|
|
2695
|
+
lastPid: null,
|
|
2696
|
+
finishedAt: nowIso(),
|
|
2697
|
+
lastError: producerTimedOut ? 'Recovered after timeout because report existed on disk.' : null,
|
|
2698
|
+
});
|
|
2699
|
+
// Skip mtime-based violation check during timeout recovery — the producer may have
|
|
2700
|
+
// been mid-write when interrupted, causing spurious mtime changes on protected files.
|
|
2701
|
+
const producerViolated = producerTimedOut ? null : detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
|
|
2702
|
+
if (producerViolated) {
|
|
2703
|
+
state.status = 'halted';
|
|
2704
|
+
state.haltReason = {
|
|
2705
|
+
type: 'protected_file_modified',
|
|
2706
|
+
stage: stageName,
|
|
2707
|
+
round: state.currentRound,
|
|
2708
|
+
details: `producer modified orchestrator-owned file ${producerViolated.file}`,
|
|
2709
|
+
blockers: [`Protected file modified: ${producerViolated.file}`],
|
|
2710
|
+
};
|
|
2711
|
+
saveState(runDir, state);
|
|
2712
|
+
appendTimeline(runDir, `Sprint halted: producer modified protected file ${producerViolated.file}`);
|
|
2713
|
+
writeStageFailureArtifacts({
|
|
2714
|
+
runDir,
|
|
2715
|
+
state,
|
|
2716
|
+
stageName,
|
|
2717
|
+
stageDir,
|
|
2718
|
+
decisionPath,
|
|
2719
|
+
scorecardPath,
|
|
2720
|
+
summary: state.haltReason.details,
|
|
2721
|
+
blockers: state.haltReason.blockers,
|
|
2722
|
+
});
|
|
2723
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
2724
|
+
cleanupWorktree({ state, runDir });
|
|
2725
|
+
throw new Error(state.haltReason.details);
|
|
2726
|
+
}
|
|
2727
|
+
appendTimeline(runDir, `producer completed stage ${stageName} round ${state.currentRound}`);
|
|
2728
|
+
// Reset consecutive timeout counter on successful completion
|
|
2729
|
+
if (!state.consecutiveTimeouts) state.consecutiveTimeouts = {};
|
|
2730
|
+
if (state.consecutiveTimeouts[stageName]) {
|
|
2731
|
+
state.consecutiveTimeouts[stageName] = 0;
|
|
2732
|
+
}
|
|
2733
|
+
// Capture git status after producer completes for reviewers to reference
|
|
2734
|
+
if (worktreeInfo?.worktreePath) {
|
|
2735
|
+
captureGitStatus({ worktreePath: worktreeInfo.worktreePath, runDir, stageDir, state });
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
heartbeatState(runDir, state, { currentRole: null });
|
|
2739
|
+
|
|
2740
|
+
// ── Phase 2: Reviewers in parallel ────────────────────────────────────────
|
|
2741
|
+
maybeAbort(runDir, state);
|
|
2742
|
+
heartbeatState(runDir, state, { currentRole: 'reviewer_a || reviewer_b' });
|
|
2743
|
+
|
|
2744
|
+
// Per-stage runtime check before launching reviewers
|
|
2745
|
+
const stageElapsedReviewers = Date.now() - stageStartedAt;
|
|
2746
|
+
if (stageElapsedReviewers > stageTimeoutMs) {
|
|
2747
|
+
state.status = 'halted';
|
|
2748
|
+
state.haltReason = {
|
|
2749
|
+
type: 'stage_timeout',
|
|
2750
|
+
stage: stageName,
|
|
2751
|
+
round: state.currentRound,
|
|
2752
|
+
details: `Stage ${stageName} exceeded ${(stageTimeoutMs / 60_000).toFixed(0)} minutes before reviewers`,
|
|
2753
|
+
blockers: [`Stage timeout before reviewers after ${(stageElapsedReviewers / 60_000).toFixed(1)} minutes`],
|
|
2754
|
+
};
|
|
2755
|
+
saveState(runDir, state);
|
|
2756
|
+
appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
|
|
2757
|
+
writeStageFailureArtifacts({
|
|
2758
|
+
runDir,
|
|
2759
|
+
state,
|
|
2760
|
+
stageName,
|
|
2761
|
+
stageDir,
|
|
2762
|
+
decisionPath,
|
|
2763
|
+
scorecardPath,
|
|
2764
|
+
summary: state.haltReason.details,
|
|
2765
|
+
blockers: state.haltReason.blockers,
|
|
2766
|
+
});
|
|
2767
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
2768
|
+
cleanupWorktree({ state, runDir });
|
|
2769
|
+
return { outcome: 'halt', summary: state.haltReason.details, blockers: state.haltReason.blockers, metrics: {} };
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} reviewers launching in parallel`);
|
|
2773
|
+
updateSummary(runDir, [
|
|
2774
|
+
`Status: ${state.status}`,
|
|
2775
|
+
`Stage: ${stageName}`,
|
|
2776
|
+
`Round: ${state.currentRound}`,
|
|
2777
|
+
'Reviewers are running in parallel.',
|
|
2778
|
+
]);
|
|
2779
|
+
|
|
2780
|
+
// Run both reviewers in parallel — each wrapped in try/catch so one failure doesn't cancel the other
|
|
2781
|
+
const reviewerResults = [];
|
|
2782
|
+
const [resultA, resultB] = await Promise.all([
|
|
2783
|
+
runReviewerRole({ runDir, state, spec, paths, role: 'reviewer_a', worktreeInfo }).catch((err) => {
|
|
2784
|
+
appendTimeline(runDir, `reviewer_a threw unhandled error: ${err.message}`);
|
|
2785
|
+
return { role: 'reviewer_a', output: null, timedOut: false, reportExisted: false, violatedFile: null, error: String(err) };
|
|
2786
|
+
}),
|
|
2787
|
+
runReviewerRole({ runDir, state, spec, paths, role: 'reviewer_b', worktreeInfo }).catch((err) => {
|
|
2788
|
+
appendTimeline(runDir, `reviewer_b threw unhandled error: ${err.message}`);
|
|
2789
|
+
return { role: 'reviewer_b', output: null, timedOut: false, reportExisted: false, violatedFile: null, error: String(err) };
|
|
2790
|
+
}),
|
|
2791
|
+
]);
|
|
2792
|
+
reviewerResults.push(resultA, resultB);
|
|
2793
|
+
|
|
2794
|
+
// Collect outputs and timeout flags
|
|
2795
|
+
const reviewerTimeouts = [];
|
|
2796
|
+
const reviewerFailures = [];
|
|
2797
|
+
const reviewerViolations = [];
|
|
2798
|
+
for (const result of reviewerResults) {
|
|
2799
|
+
if (result.error) {
|
|
2800
|
+
appendTimeline(runDir, `${result.role} error: ${result.error}`);
|
|
2801
|
+
}
|
|
2802
|
+
outputs[result.role] = result.output;
|
|
2803
|
+
if (result.attemptErrors?.length) {
|
|
2804
|
+
reviewerFailures.push({
|
|
2805
|
+
role: result.role,
|
|
2806
|
+
summary: result.attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}: ${item.error}`).join(' | '),
|
|
2807
|
+
attempts: result.attemptErrors,
|
|
2808
|
+
});
|
|
2809
|
+
appendTimeline(runDir, `${result.role} failed attempts: ${result.attemptErrors.map((item) => `${item.label}=${item.agent}/${item.model}`).join(', ')}`);
|
|
2810
|
+
}
|
|
2811
|
+
if (result.timedOut) {
|
|
2812
|
+
appendTimeline(runDir, `${result.role} timed out stage ${stageName} round ${state.currentRound}`);
|
|
2813
|
+
reviewerTimeouts.push({ role: result.role, timedOut: true, hadReport: result.reportExisted });
|
|
2814
|
+
}
|
|
2815
|
+
if (result.violatedFile) {
|
|
2816
|
+
appendTimeline(runDir, `${result.role} modified protected file ${result.violatedFile} — report invalidated`);
|
|
2817
|
+
reviewerViolations.push({ role: result.role, violatedFile: result.violatedFile });
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
heartbeatState(runDir, state, { currentRole: null });
|
|
2821
|
+
|
|
2822
|
+
// ── Phase 3: Global reviewer (sequential, only if required) ──────────────
|
|
2823
|
+
const stageCriteria = spec.stageCriteria?.[stageName] ?? {};
|
|
2824
|
+
const globalReviewerRequired = stageCriteria.globalReviewerRequired === true;
|
|
2825
|
+
let globalReviewerOutput = null;
|
|
2826
|
+
let globalReviewerTimedOut = false;
|
|
2827
|
+
|
|
2828
|
+
if (globalReviewerRequired) {
|
|
2829
|
+
maybeAbort(runDir, state);
|
|
2830
|
+
heartbeatState(runDir, state, { currentRole: 'global_reviewer' });
|
|
2831
|
+
appendTimeline(runDir, `Stage ${stageName} round ${state.currentRound} global_reviewer is required — running sequentially after A/B`);
|
|
2832
|
+
|
|
2833
|
+
const globalReviewerConfig = roleConfig(spec, 'global_reviewer');
|
|
2834
|
+
if (!globalReviewerConfig) {
|
|
2835
|
+
appendTimeline(runDir, `global_reviewer config not found in spec — skipping`);
|
|
2836
|
+
} else {
|
|
2837
|
+
const globalReviewerPrompt = buildRolePrompt({
|
|
2838
|
+
spec,
|
|
2839
|
+
stage: stageName,
|
|
2840
|
+
round: state.currentRound,
|
|
2841
|
+
role: 'global_reviewer',
|
|
2842
|
+
runDir,
|
|
2843
|
+
stageDir,
|
|
2844
|
+
briefPath,
|
|
2845
|
+
producerPath,
|
|
2846
|
+
reviewerAPath,
|
|
2847
|
+
reviewerBPath,
|
|
2848
|
+
globalReviewerPath: paths.globalReviewerPath,
|
|
2849
|
+
workUnit,
|
|
2850
|
+
});
|
|
2851
|
+
const { reportPath: grReportPath, stdoutPath: grStdoutPath } = roleArtifactPaths(paths, 'global_reviewer');
|
|
2852
|
+
|
|
2853
|
+
if (fileExists(grReportPath) && fs.readFileSync(grReportPath, 'utf8').trim()) {
|
|
2854
|
+
globalReviewerOutput = fs.readFileSync(grReportPath, 'utf8').trim();
|
|
2855
|
+
updateRoleState(paths, 'global_reviewer', {
|
|
2856
|
+
stage: stageName,
|
|
2857
|
+
round: state.currentRound,
|
|
2858
|
+
status: 'completed',
|
|
2859
|
+
lastPid: null,
|
|
2860
|
+
finishedAt: nowIso(),
|
|
2861
|
+
timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
|
|
2862
|
+
lastError: null,
|
|
2863
|
+
});
|
|
2864
|
+
appendTimeline(runDir, `global_reviewer skipped (report already exists) stage ${stageName} round ${state.currentRound}`);
|
|
2865
|
+
} else {
|
|
2866
|
+
updateRoleState(paths, 'global_reviewer', {
|
|
2867
|
+
stage: stageName,
|
|
2868
|
+
round: state.currentRound,
|
|
2869
|
+
status: 'running',
|
|
2870
|
+
startedAt: nowIso(),
|
|
2871
|
+
finishedAt: null,
|
|
2872
|
+
terminatedAt: null,
|
|
2873
|
+
lastPid: null,
|
|
2874
|
+
timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
|
|
2875
|
+
lastError: null,
|
|
2876
|
+
});
|
|
2877
|
+
const protectedSnapshot = snapshotProtectedFiles(protectedFiles);
|
|
2878
|
+
const grFailLog = path.join(stageDir, 'global-reviewer-failure.log');
|
|
2879
|
+
let grOutput;
|
|
2880
|
+
try {
|
|
2881
|
+
grOutput = runAgent({
|
|
2882
|
+
cwd: worktreeInfo?.worktreePath ?? (fileExists(spec.branchWorkspace) ? spec.branchWorkspace : spec.workspace),
|
|
2883
|
+
agent: globalReviewerConfig.agent,
|
|
2884
|
+
model: globalReviewerConfig.model,
|
|
2885
|
+
prompt: globalReviewerPrompt,
|
|
2886
|
+
timeoutSeconds: stageRoleTimeout(spec, stageName, 'global_reviewer'),
|
|
2887
|
+
failLogPath: grFailLog,
|
|
2888
|
+
promptDir: runDir,
|
|
2889
|
+
runDir,
|
|
2890
|
+
roleLabel: 'global_reviewer',
|
|
2891
|
+
});
|
|
2892
|
+
} catch (grErr) {
|
|
2893
|
+
appendTimeline(runDir, `global_reviewer error: ${grErr.message}`);
|
|
2894
|
+
grOutput = null;
|
|
2895
|
+
updateRoleState(paths, 'global_reviewer', {
|
|
2896
|
+
status: grErr.message?.includes('timed out') ? 'timed_out' : 'error',
|
|
2897
|
+
lastPid: null,
|
|
2898
|
+
finishedAt: nowIso(),
|
|
2899
|
+
terminatedAt: grErr.message?.includes('timed out') ? nowIso() : null,
|
|
2900
|
+
lastError: grErr.message ?? String(grErr),
|
|
2901
|
+
});
|
|
2902
|
+
}
|
|
2903
|
+
if (grOutput) writeText(grStdoutPath, `${grOutput}\n`);
|
|
2904
|
+
globalReviewerOutput = readRoleOutput({ reportPath: grReportPath, stdout: grOutput ?? '' });
|
|
2905
|
+
if (globalReviewerOutput && !reportExistsAndNonEmpty(grReportPath)) {
|
|
2906
|
+
writeText(grReportPath, `${globalReviewerOutput}\n`);
|
|
2907
|
+
}
|
|
2908
|
+
if (reportExistsAndNonEmpty(grReportPath)) {
|
|
2909
|
+
updateRoleState(paths, 'global_reviewer', {
|
|
2910
|
+
status: 'completed',
|
|
2911
|
+
lastPid: null,
|
|
2912
|
+
finishedAt: nowIso(),
|
|
2913
|
+
lastError: null,
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
const grViolated = detectProtectedWriteViolation(protectedFiles, protectedSnapshot, runDir);
|
|
2917
|
+
if (grViolated) {
|
|
2918
|
+
appendTimeline(runDir, `global_reviewer modified protected file ${grViolated.file} — report invalidated`);
|
|
2919
|
+
reviewerViolations.push({ role: 'global_reviewer', violatedFile: grViolated.file });
|
|
2920
|
+
}
|
|
2921
|
+
if (reportExistsAndNonEmpty(grReportPath)) {
|
|
2922
|
+
appendTimeline(runDir, `global_reviewer completed stage ${stageName} round ${state.currentRound}`);
|
|
2923
|
+
} else {
|
|
2924
|
+
appendTimeline(runDir, `global_reviewer produced no report for stage ${stageName} round ${state.currentRound}`);
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
heartbeatState(runDir, state, { currentRole: null });
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
return decideAndPersist({
|
|
2932
|
+
runDir,
|
|
2933
|
+
stageName,
|
|
2934
|
+
stageDir,
|
|
2935
|
+
decisionPath,
|
|
2936
|
+
scorecardPath,
|
|
2937
|
+
producerPath,
|
|
2938
|
+
reviewerAPath,
|
|
2939
|
+
reviewerBPath,
|
|
2940
|
+
globalReviewerPath,
|
|
2941
|
+
state,
|
|
2942
|
+
reviewerTimeouts: reviewerTimeouts.length > 0 ? reviewerTimeouts : null,
|
|
2943
|
+
reviewerViolations: reviewerViolations.length > 0 ? reviewerViolations : null,
|
|
2944
|
+
reviewerFailures: reviewerFailures.length > 0 ? reviewerFailures : null,
|
|
2945
|
+
});
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
function abortRun(runId) {
|
|
2949
|
+
const runDir = path.join(sprintRoot, runId);
|
|
2950
|
+
const sprintFile = path.join(runDir, 'sprint.json');
|
|
2951
|
+
if (!fileExists(sprintFile)) {
|
|
2952
|
+
throw new Error(`Run not found: ${runId}`);
|
|
2953
|
+
}
|
|
2954
|
+
const state = readJson(sprintFile);
|
|
2955
|
+
state.status = 'aborted';
|
|
2956
|
+
state.haltReason = {
|
|
2957
|
+
type: 'operator_abort',
|
|
2958
|
+
stage: state.currentStage,
|
|
2959
|
+
round: state.currentRound,
|
|
2960
|
+
details: 'Aborted by operator',
|
|
2961
|
+
blockers: [],
|
|
2962
|
+
};
|
|
2963
|
+
saveState(runDir, state);
|
|
2964
|
+
updateSummary(runDir, [
|
|
2965
|
+
`Status: ${state.status}`,
|
|
2966
|
+
`Stage: ${state.currentStage}`,
|
|
2967
|
+
`Round: ${state.currentRound}`,
|
|
2968
|
+
'Operator aborted this sprint.',
|
|
2969
|
+
]);
|
|
2970
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
2971
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
2972
|
+
terminateProcessTree(state.orchestratorPid, { runDir, label: 'orchestrator' });
|
|
2973
|
+
cleanupWorktree({ state, runDir });
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
function pauseRun(runId) {
|
|
2977
|
+
const runDir = path.join(sprintRoot, runId);
|
|
2978
|
+
const sprintFile = path.join(runDir, 'sprint.json');
|
|
2979
|
+
if (!fileExists(sprintFile)) {
|
|
2980
|
+
throw new Error(`Run not found: ${runId}`);
|
|
2981
|
+
}
|
|
2982
|
+
const state = readJson(sprintFile);
|
|
2983
|
+
state.status = 'paused';
|
|
2984
|
+
state.haltReason = {
|
|
2985
|
+
type: 'operator_pause',
|
|
2986
|
+
stage: state.currentStage,
|
|
2987
|
+
round: state.currentRound,
|
|
2988
|
+
details: 'Paused by operator',
|
|
2989
|
+
blockers: [],
|
|
2990
|
+
};
|
|
2991
|
+
saveState(runDir, state);
|
|
2992
|
+
updateSummary(runDir, [
|
|
2993
|
+
`Status: ${state.status}`,
|
|
2994
|
+
`Stage: ${state.currentStage}`,
|
|
2995
|
+
`Round: ${state.currentRound}`,
|
|
2996
|
+
'Operator paused this sprint.',
|
|
2997
|
+
]);
|
|
2998
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
2999
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
function listRuns() {
|
|
3003
|
+
ensureDir(sprintRoot);
|
|
3004
|
+
const entries = [];
|
|
3005
|
+
for (const entry of fs.readdirSync(sprintRoot)) {
|
|
3006
|
+
const sprintFile = path.join(sprintRoot, entry, 'sprint.json');
|
|
3007
|
+
if (!fileExists(sprintFile)) continue;
|
|
3008
|
+
try {
|
|
3009
|
+
const state = readJson(sprintFile);
|
|
3010
|
+
const elapsedMin = ((Date.now() - (Date.parse(state.createdAt) || Date.now())) / 60_000).toFixed(0);
|
|
3011
|
+
entries.push({
|
|
3012
|
+
runId: entry,
|
|
3013
|
+
task: state.taskId ?? '?',
|
|
3014
|
+
title: (state.title ?? '').slice(0, 40),
|
|
3015
|
+
status: state.status ?? '?',
|
|
3016
|
+
stage: `${state.currentStage ?? '?'} (${(state.currentStageIndex ?? 0) + 1})`,
|
|
3017
|
+
round: state.currentRound ?? '?',
|
|
3018
|
+
elapsed: `${elapsedMin}m`,
|
|
3019
|
+
});
|
|
3020
|
+
} catch {}
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
if (entries.length === 0) {
|
|
3024
|
+
console.log('No sprint runs found.');
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
// Sort by createdAt descending (newest first)
|
|
3029
|
+
entries.reverse();
|
|
3030
|
+
|
|
3031
|
+
const statusIcon = (s) => s === 'completed' ? '[OK]' : s === 'running' ? '>>>' : s === 'halted' ? '[!!]' : s === 'paused' ? '[||]' : '[??]';
|
|
3032
|
+
|
|
3033
|
+
const lines = [
|
|
3034
|
+
'=== Sprint Runs ===',
|
|
3035
|
+
'',
|
|
3036
|
+
];
|
|
3037
|
+
|
|
3038
|
+
for (const e of entries) {
|
|
3039
|
+
lines.push(` ${statusIcon(e.status)} ${e.status.padEnd(10)} ${e.runId.slice(0, 50)}`);
|
|
3040
|
+
lines.push(` ${e.task} | stage ${e.stage} | round ${e.round} | ${e.elapsed}`);
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
lines.push('');
|
|
3044
|
+
lines.push(` Total: ${entries.length} runs`);
|
|
3045
|
+
console.log(lines.join('\n'));
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
function showStatus(runId) {
|
|
3049
|
+
const runDir = path.join(sprintRoot, runId);
|
|
3050
|
+
const sprintFile = path.join(runDir, 'sprint.json');
|
|
3051
|
+
if (!fileExists(sprintFile)) {
|
|
3052
|
+
throw new Error(`Run not found: ${runId}`);
|
|
3053
|
+
}
|
|
3054
|
+
const state = reconcileRunState(runDir, readJson(sprintFile));
|
|
3055
|
+
const spec = loadSpec(state);
|
|
3056
|
+
const now = Date.now();
|
|
3057
|
+
const createdMs = Date.parse(state.createdAt) || now;
|
|
3058
|
+
const heartbeatMs = Date.parse(state.lastHeartbeatAt) || now;
|
|
3059
|
+
const elapsedMin = ((now - createdMs) / 60_000).toFixed(1);
|
|
3060
|
+
const heartbeatAgeSec = ((now - heartbeatMs) / 1000).toFixed(0);
|
|
3061
|
+
const maxRuntime = spec.maxRuntimeMinutes ?? 90;
|
|
3062
|
+
const maxRounds = state.maxRoundsPerStage ?? 3;
|
|
3063
|
+
|
|
3064
|
+
// Find latest scorecard
|
|
3065
|
+
const stageDirName = `${String(state.currentStageIndex + 1).padStart(2, '0')}-${state.currentStage}`;
|
|
3066
|
+
const scorecardFile = path.join(runDir, 'stages', stageDirName, 'scorecard.json');
|
|
3067
|
+
const scorecard = fileExists(scorecardFile) ? readJson(scorecardFile) : null;
|
|
3068
|
+
|
|
3069
|
+
// Count completed stages by scanning decision files
|
|
3070
|
+
const stagesDir = path.join(runDir, 'stages');
|
|
3071
|
+
const completedStages = [];
|
|
3072
|
+
if (fileExists(stagesDir)) {
|
|
3073
|
+
for (const entry of fs.readdirSync(stagesDir)) {
|
|
3074
|
+
const decFile = path.join(stagesDir, entry, 'decision.md');
|
|
3075
|
+
if (fileExists(decFile)) {
|
|
3076
|
+
const text = fs.readFileSync(decFile, 'utf8');
|
|
3077
|
+
const outcomeMatch = text.match(/Outcome:\s*(\w+)/);
|
|
3078
|
+
completedStages.push({ dir: entry, outcome: outcomeMatch?.[1] ?? 'unknown' });
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// Latest timeline entries (last 8)
|
|
3084
|
+
const timelineFile = path.join(runDir, 'timeline.md');
|
|
3085
|
+
let recentEvents = [];
|
|
3086
|
+
if (fileExists(timelineFile)) {
|
|
3087
|
+
recentEvents = fs.readFileSync(timelineFile, 'utf8')
|
|
3088
|
+
.split('\n')
|
|
3089
|
+
.filter((l) => l.startsWith('- '))
|
|
3090
|
+
.slice(-8)
|
|
3091
|
+
.map((l) => l.replace(/^- (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\d{3}Z\s*/, ''));
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Build driver dashboard
|
|
3095
|
+
const statusIcon = state.status === 'running' ? '>>>' : state.status === 'completed' ? '[OK]' : '[!!]';
|
|
3096
|
+
const aliveHint = state.status === 'running'
|
|
3097
|
+
? (parseInt(heartbeatAgeSec, 10) > 120 ? `WARNING: heartbeat ${heartbeatAgeSec}s ago` : `heartbeat ${heartbeatAgeSec}s ago`)
|
|
3098
|
+
: '';
|
|
3099
|
+
|
|
3100
|
+
const lines = [
|
|
3101
|
+
`=== Sprint Dashboard ===`,
|
|
3102
|
+
'',
|
|
3103
|
+
` ${statusIcon} ${state.status.toUpperCase()}`,
|
|
3104
|
+
` Task: ${state.title}`,
|
|
3105
|
+
` Run: ${runId}`,
|
|
3106
|
+
'',
|
|
3107
|
+
` Stage: ${state.currentStage} (${state.currentStageIndex + 1}/${spec.stages.length})`,
|
|
3108
|
+
` Round: ${state.currentRound} / ${maxRounds}`,
|
|
3109
|
+
` Role: ${state.currentRole ?? 'idle'}`,
|
|
3110
|
+
'',
|
|
3111
|
+
` Elapsed: ${elapsedMin} min / ${maxRuntime} min max`,
|
|
3112
|
+
` ${aliveHint}`,
|
|
3113
|
+
'',
|
|
3114
|
+
];
|
|
3115
|
+
|
|
3116
|
+
// Stage progress bar
|
|
3117
|
+
if (spec.stages.length > 0) {
|
|
3118
|
+
lines.push(' Stages:');
|
|
3119
|
+
for (const stage of spec.stages) {
|
|
3120
|
+
const done = completedStages.find((s) => s.dir.includes(stage));
|
|
3121
|
+
const isCurrent = stage === state.currentStage;
|
|
3122
|
+
const marker = done ? (done.outcome === 'advance' ? ' [PASS]' : ` [${done.outcome}]`) : (isCurrent ? ' >>' : ' --');
|
|
3123
|
+
lines.push(` ${stage}${marker}`);
|
|
3124
|
+
}
|
|
3125
|
+
lines.push('');
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// Latest scorecard
|
|
3129
|
+
if (scorecard) {
|
|
3130
|
+
lines.push(' Latest round verdict:');
|
|
3131
|
+
lines.push(` reviewer_a: ${scorecard.reviewerAVerdict ?? '?'}`);
|
|
3132
|
+
lines.push(` reviewer_b: ${scorecard.reviewerBVerdict ?? '?'}`);
|
|
3133
|
+
lines.push(` approvals: ${scorecard.approvalCount ?? 0} / 2 required`);
|
|
3134
|
+
if (scorecard.failureClassification) {
|
|
3135
|
+
lines.push(` failure: ${scorecard.failureClassification}`);
|
|
3136
|
+
lines.push(` source: ${scorecard.failureSource ?? 'n/a'}`);
|
|
3137
|
+
}
|
|
3138
|
+
if (scorecard.blockerCount > 0) {
|
|
3139
|
+
lines.push(` blockers: ${scorecard.blockerCount}`);
|
|
3140
|
+
for (const b of (scorecard.blockers ?? []).slice(0, 3)) {
|
|
3141
|
+
lines.push(` - ${b.slice(0, 120)}`);
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
lines.push('');
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// Recent events
|
|
3148
|
+
if (recentEvents.length > 0) {
|
|
3149
|
+
lines.push(' Recent events:');
|
|
3150
|
+
for (const evt of recentEvents) {
|
|
3151
|
+
lines.push(` ${evt}`);
|
|
3152
|
+
}
|
|
3153
|
+
lines.push('');
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// Controls reminder
|
|
3157
|
+
lines.push(' Controls:');
|
|
3158
|
+
lines.push(` npm run ai-sprint -- --status ${runId}`);
|
|
3159
|
+
lines.push(` npm run ai-sprint -- --pause ${runId}`);
|
|
3160
|
+
lines.push(` npm run ai-sprint -- --abort ${runId}`);
|
|
3161
|
+
lines.push(` npm run ai-sprint -- --resume ${runId}`);
|
|
3162
|
+
lines.push('');
|
|
3163
|
+
|
|
3164
|
+
console.log(lines.join('\n'));
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
async function main() {
|
|
3168
|
+
const args = parseArgs(process.argv.slice(2));
|
|
3169
|
+
|
|
3170
|
+
if (args.help) {
|
|
3171
|
+
console.log([
|
|
3172
|
+
'AI Sprint Orchestrator',
|
|
3173
|
+
'',
|
|
3174
|
+
'Usage:',
|
|
3175
|
+
' node run.mjs --self-check Validate package-local environment',
|
|
3176
|
+
' node run.mjs --task <task-id> [--task-spec <path>] Start a new sprint',
|
|
3177
|
+
' node run.mjs --resume <run-id> Resume a halted/aborted sprint',
|
|
3178
|
+
' node run.mjs --status <run-id> Show sprint dashboard',
|
|
3179
|
+
' node run.mjs --list List all sprint runs',
|
|
3180
|
+
' node run.mjs --pause <run-id> Pause a running sprint',
|
|
3181
|
+
' node run.mjs --abort <run-id> Abort a sprint',
|
|
3182
|
+
' node run.mjs --archive <run-id> Archive a completed/halted sprint',
|
|
3183
|
+
' node run.mjs --task <task-id> --runtime-root <path> Override runtime output root',
|
|
3184
|
+
'',
|
|
3185
|
+
'Sprints auto-archive on completion or halt.',
|
|
3186
|
+
`Task specs are loaded from ${path.relative(packageRoot, path.join(referencesRoot, 'specs'))}/<task-id>.json`,
|
|
3187
|
+
`Runtime output defaults to ${defaultRuntimeRoot}`,
|
|
3188
|
+
'Override with --task-spec <path> to use a custom spec file.',
|
|
3189
|
+
].join('\n'));
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
if (args['self-check']) {
|
|
3194
|
+
if (args.runtimeRoot) {
|
|
3195
|
+
configureRuntimeRoots(args.runtimeRoot);
|
|
3196
|
+
} else {
|
|
3197
|
+
configureRuntimeRoots(runtimeRoot);
|
|
3198
|
+
}
|
|
3199
|
+
runSelfCheck();
|
|
3200
|
+
return;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
if (args.list) {
|
|
3204
|
+
listRuns();
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
if (args.archive) {
|
|
3209
|
+
try {
|
|
3210
|
+
const archiveDir = archiveRunById(args.archive);
|
|
3211
|
+
console.log(`Archived: ${archiveDir}`);
|
|
3212
|
+
} catch (err) {
|
|
3213
|
+
console.error(`Archive failed: ${err.message}`);
|
|
3214
|
+
process.exitCode = 1;
|
|
3215
|
+
}
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
if (args.abort) {
|
|
3220
|
+
abortRun(args.abort);
|
|
3221
|
+
console.log(`Aborted sprint ${args.abort}`);
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
if (args.pause) {
|
|
3226
|
+
pauseRun(args.pause);
|
|
3227
|
+
console.log(`Paused sprint ${args.pause}`);
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
if (args.status) {
|
|
3232
|
+
showStatus(args.status);
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
if (args.runtimeRoot) {
|
|
3237
|
+
configureRuntimeRoots(args.runtimeRoot);
|
|
3238
|
+
} else {
|
|
3239
|
+
configureRuntimeRoots(runtimeRoot);
|
|
3240
|
+
}
|
|
3241
|
+
|
|
3242
|
+
const { runDir, state } = loadOrInitState(args);
|
|
3243
|
+
const spec = loadSpec(state, args);
|
|
3244
|
+
|
|
3245
|
+
// Register graceful shutdown handlers now that state and runDir are available.
|
|
3246
|
+
// This ensures uncaught exceptions during the sprint loop write the halted state
|
|
3247
|
+
// to disk instead of leaving it stuck at 'running'.
|
|
3248
|
+
const gracefulHalt = (signal, detail) => {
|
|
3249
|
+
if (state.status === 'running') {
|
|
3250
|
+
state.status = 'halted';
|
|
3251
|
+
state.haltReason = {
|
|
3252
|
+
type: 'uncaught_exception',
|
|
3253
|
+
stage: state.currentStage,
|
|
3254
|
+
round: state.currentRound,
|
|
3255
|
+
details: String(detail),
|
|
3256
|
+
blockers: [String(detail)],
|
|
3257
|
+
};
|
|
3258
|
+
try {
|
|
3259
|
+
saveState(runDir, state);
|
|
3260
|
+
appendTimeline(runDir, `Sprint halted by uncaught ${signal}: ${String(detail)}`);
|
|
3261
|
+
} catch (saveErr) {
|
|
3262
|
+
console.error(`Failed to write halted state: ${saveErr.message}`);
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
console.error(`Fatal ${signal}: ${detail}`);
|
|
3266
|
+
process.exitCode = 1;
|
|
3267
|
+
};
|
|
3268
|
+
process.on('uncaughtException', (err) => {
|
|
3269
|
+
gracefulHalt('uncaughtException', err.stack ?? err.message);
|
|
3270
|
+
});
|
|
3271
|
+
process.on('unhandledRejection', (reason) => {
|
|
3272
|
+
gracefulHalt('unhandledRejection', String(reason));
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
// Pre-flight check: verify acpx is available (the actual execution entry point).
|
|
3276
|
+
// We do NOT check agent names with 'which' because acpx resolves agents via its
|
|
3277
|
+
// own registry (npx, built-in commands, etc.). Checking agent binaries directly
|
|
3278
|
+
// would cause false positives and is Linux-only.
|
|
3279
|
+
if (!args.status && !args.list && !args.archive && !args.abort && !args.pause) {
|
|
3280
|
+
const acpxCheck = checkAcpxAvailable();
|
|
3281
|
+
if (acpxCheck.status !== 0) {
|
|
3282
|
+
appendTimeline(runDir, `Pre-flight check FAILED: acpx not available`);
|
|
3283
|
+
throw new Error(`acpx not available. Install it first: npm install -g acpx`);
|
|
3284
|
+
}
|
|
3285
|
+
appendTimeline(runDir, `Pre-flight check OK: acpx available`);
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
const sprintStartedAt = Date.parse(state.createdAt) || Date.now();
|
|
3289
|
+
|
|
3290
|
+
// Acquire exclusive lock to prevent concurrent orchestrators
|
|
3291
|
+
const lockPath = path.join(runDir, 'orchestrator.lock');
|
|
3292
|
+
if (fileExists(lockPath)) {
|
|
3293
|
+
const lockData = readJson(lockPath);
|
|
3294
|
+
const lockAge = Date.now() - Date.parse(lockData.acquiredAt || 0);
|
|
3295
|
+
if (lockData.pid && pidExists(lockData.pid) && lockData.pid !== process.pid) {
|
|
3296
|
+
if (lockAge < 30 * 60 * 1000) { // lock valid for 30 min
|
|
3297
|
+
throw new Error(`Another orchestrator (PID ${lockData.pid}) is running. Acquired at ${lockData.acquiredAt}. Aborting.`);
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
// Clean up expired or stale lock
|
|
3301
|
+
if (lockAge >= 30 * 60 * 1000 || !pidExists(lockData.pid)) {
|
|
3302
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
writeJson(lockPath, { pid: process.pid, acquiredAt: nowIso() });
|
|
3306
|
+
|
|
3307
|
+
appendTimeline(runDir, state.currentRound === 1 && state.currentStageIndex === 0 ? 'Sprint execution started' : 'Sprint resumed');
|
|
3308
|
+
|
|
3309
|
+
try {
|
|
3310
|
+
while (state.status === 'running') {
|
|
3311
|
+
heartbeatState(runDir, state);
|
|
3312
|
+
|
|
3313
|
+
// Global runtime check
|
|
3314
|
+
const elapsedMinutes = (Date.now() - sprintStartedAt) / 60_000;
|
|
3315
|
+
const maxRuntime = state.maxRuntimeMinutes ?? spec.maxRuntimeMinutes ?? 90;
|
|
3316
|
+
if (elapsedMinutes > maxRuntime) {
|
|
3317
|
+
state.status = 'halted';
|
|
3318
|
+
state.haltReason = {
|
|
3319
|
+
type: 'max_runtime_exceeded',
|
|
3320
|
+
stage: state.currentStage,
|
|
3321
|
+
round: state.currentRound,
|
|
3322
|
+
details: `Sprint exceeded ${maxRuntime} minutes (elapsed: ${elapsedMinutes.toFixed(1)}min)`,
|
|
3323
|
+
blockers: ['Runtime limit exceeded.'],
|
|
3324
|
+
};
|
|
3325
|
+
saveState(runDir, state);
|
|
3326
|
+
appendTimeline(runDir, `Sprint halted: ${state.haltReason.details}`);
|
|
3327
|
+
updateSummaryWithClassification(runDir, [
|
|
3328
|
+
`Status: ${state.status}`,
|
|
3329
|
+
`Stage: ${state.currentStage}`,
|
|
3330
|
+
`Round: ${state.currentRound}`,
|
|
3331
|
+
`Halt reason: ${state.haltReason.details}`,
|
|
3332
|
+
], readStageFailureClassification(runDir, state));
|
|
3333
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
3334
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
3335
|
+
break;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
const decision = await executeStage(runDir, state, spec);
|
|
3339
|
+
advanceState(state, spec, decision, { runDir });
|
|
3340
|
+
saveState(runDir, state);
|
|
3341
|
+
if (state.status === 'completed') {
|
|
3342
|
+
cleanupWorktree({ state, runDir });
|
|
3343
|
+
appendTimeline(runDir, 'Sprint completed');
|
|
3344
|
+
updateSummary(runDir, [
|
|
3345
|
+
`Status: ${state.status}`,
|
|
3346
|
+
`Stage: ${state.currentStage}`,
|
|
3347
|
+
`Round: ${state.currentRound}`,
|
|
3348
|
+
...(state.mergePending
|
|
3349
|
+
? [`Merge pending: push branch '${state.mergePending.targetBranch}' before merge.`]
|
|
3350
|
+
: ['All stages finished.']),
|
|
3351
|
+
]);
|
|
3352
|
+
}
|
|
3353
|
+
if (state.status === 'halted') {
|
|
3354
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
3355
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
3356
|
+
cleanupWorktree({ state, runDir });
|
|
3357
|
+
appendTimeline(runDir, `Sprint halted: ${state.haltReason?.details ?? 'unknown reason'}`);
|
|
3358
|
+
updateSummaryWithClassification(runDir, [
|
|
3359
|
+
`Status: ${state.status}`,
|
|
3360
|
+
`Stage: ${state.currentStage}`,
|
|
3361
|
+
`Round: ${state.currentRound}`,
|
|
3362
|
+
`Halt reason: ${state.haltReason?.details ?? 'unknown reason'}`,
|
|
3363
|
+
], readStageFailureClassification(runDir, state));
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
} catch (error) {
|
|
3367
|
+
if (state.status !== 'halted') {
|
|
3368
|
+
state.status = 'halted';
|
|
3369
|
+
state.haltReason = {
|
|
3370
|
+
type: 'orchestrator_error',
|
|
3371
|
+
stage: state.currentStage,
|
|
3372
|
+
round: state.currentRound,
|
|
3373
|
+
details: String(error),
|
|
3374
|
+
blockers: [String(error)],
|
|
3375
|
+
};
|
|
3376
|
+
const paths = ensureStagePaths(runDir, state.currentStageIndex, state.currentStage);
|
|
3377
|
+
cleanupRecordedRoleProcesses(paths, runDir);
|
|
3378
|
+
cleanupWorktree({ state, runDir });
|
|
3379
|
+
saveState(runDir, state);
|
|
3380
|
+
appendTimeline(runDir, `Sprint halted by orchestrator error: ${String(error)}`);
|
|
3381
|
+
updateSummaryWithClassification(runDir, [
|
|
3382
|
+
`Status: ${state.status}`,
|
|
3383
|
+
`Stage: ${state.currentStage}`,
|
|
3384
|
+
`Round: ${state.currentRound}`,
|
|
3385
|
+
`Halt reason: ${String(error)}`,
|
|
3386
|
+
], readStageFailureClassification(runDir, state) ?? inferFailureClassification({
|
|
3387
|
+
summary: String(error),
|
|
3388
|
+
blockers: [String(error)],
|
|
3389
|
+
}));
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// Auto-archive if sprint reached a terminal state
|
|
3394
|
+
if (state.status === 'completed' || state.status === 'halted') {
|
|
3395
|
+
try {
|
|
3396
|
+
const archiveDir = archiveRunById(args.resume || path.basename(runDir));
|
|
3397
|
+
appendTimeline(runDir, `Auto-archived to ${path.relative(runtimeRoot, archiveDir)}`);
|
|
3398
|
+
console.log(`Archived: ${archiveDir}`);
|
|
3399
|
+
} catch (archiveErr) {
|
|
3400
|
+
// Archive failure must not block orchestrator exit
|
|
3401
|
+
console.error(`Auto-archive failed: ${archiveErr.message}`);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
// Release lock
|
|
3406
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
3407
|
+
|
|
3408
|
+
console.log(runDir);
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// Only run main() when executed directly, not when imported for testing
|
|
3412
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
3413
|
+
main().catch((err) => {
|
|
3414
|
+
// main() is async and may throw. The try/catch inside main() handles
|
|
3415
|
+
// errors within its body, but rejections from the Promise itself land here.
|
|
3416
|
+
console.error('Fatal error:', err.message);
|
|
3417
|
+
process.exit(1);
|
|
3418
|
+
});
|
|
3419
|
+
}
|