principles-disciple 1.80.0 → 1.82.0

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