@magnusekdahl/parallix 1.0.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 (123) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/LICENSE +661 -0
  3. package/README.md +196 -0
  4. package/config/agents.json +25 -0
  5. package/config/agents.local.json.template +8 -0
  6. package/config/state-map.json +4 -0
  7. package/config/state-map.json.template +31 -0
  8. package/config/workflow.config.schema.json +98 -0
  9. package/data/.gitkeep +0 -0
  10. package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
  11. package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
  12. package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
  13. package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
  14. package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
  15. package/docs/adr/0041-integration-pipeline-gates.md +165 -0
  16. package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
  17. package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
  18. package/docs/adr/0044-workflow-distribution-model.md +277 -0
  19. package/docs/adr/0045-parallax-branch-model.md +182 -0
  20. package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
  21. package/docs/adr/index.md +20 -0
  22. package/docs/agents.md +212 -0
  23. package/docs/authority-reference.md +298 -0
  24. package/docs/forgejo-setup.md +31 -0
  25. package/docs/migration/extraction.md +61 -0
  26. package/docs/migration/task-classification.md +36 -0
  27. package/docs/operator-setup.md +76 -0
  28. package/docs/readme-rewrite-benchmark.md +188 -0
  29. package/docs/use-cases.md +105 -0
  30. package/examples/README.md +62 -0
  31. package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
  32. package/examples/run-verify-env-smoke.sh +40 -0
  33. package/index.js +250 -0
  34. package/lib/README.md +13 -0
  35. package/lib/agents/agents.js +867 -0
  36. package/lib/agents/claude-telemetry.js +233 -0
  37. package/lib/agents/claude.js +139 -0
  38. package/lib/agents/codex-telemetry.js +202 -0
  39. package/lib/agents/codex.js +219 -0
  40. package/lib/agents/limit-hit.js +252 -0
  41. package/lib/agents/mistral-telemetry.js +44 -0
  42. package/lib/agents/mistral.js +68 -0
  43. package/lib/agents/opencode-export.js +110 -0
  44. package/lib/agents/opencode-telemetry.js +356 -0
  45. package/lib/agents/opencode.js +218 -0
  46. package/lib/agents/stage-telemetry.js +37 -0
  47. package/lib/commands/active.js +625 -0
  48. package/lib/commands/checkpoint.js +76 -0
  49. package/lib/commands/config.js +39 -0
  50. package/lib/commands/coverage-gate.js +358 -0
  51. package/lib/commands/diff.js +119 -0
  52. package/lib/commands/draft.js +854 -0
  53. package/lib/commands/handoff.js +501 -0
  54. package/lib/commands/integrate.js +1528 -0
  55. package/lib/commands/mission-start.js +246 -0
  56. package/lib/commands/rebase.js +597 -0
  57. package/lib/commands/repair-handoff.js +227 -0
  58. package/lib/commands/resolve-conflict.js +109 -0
  59. package/lib/commands/review.js +13 -0
  60. package/lib/commands/setup-review.js +13 -0
  61. package/lib/commands/setup.js +3 -0
  62. package/lib/commands/stats-backfill.js +395 -0
  63. package/lib/commands/stats.js +1601 -0
  64. package/lib/commands/status.js +183 -0
  65. package/lib/commands/verify.js +1 -0
  66. package/lib/core/fmt.js +202 -0
  67. package/lib/core/git.js +73 -0
  68. package/lib/core/gitignore.js +110 -0
  69. package/lib/core/mission-utils.js +1017 -0
  70. package/lib/core/persistent-data-migration.js +201 -0
  71. package/lib/core/product-config.js +508 -0
  72. package/lib/core/runtime-matrix.js +82 -0
  73. package/lib/core/spawn-tee.js +173 -0
  74. package/lib/core/state-map.js +89 -0
  75. package/lib/core/storage.js +165 -0
  76. package/lib/core/verification.js +149 -0
  77. package/lib/index.js +77 -0
  78. package/lib/review/rebase.js +163 -0
  79. package/lib/review/review-adapter.js +135 -0
  80. package/lib/review/review-artifacts.js +619 -0
  81. package/lib/review/review-commands.js +1375 -0
  82. package/lib/review/review-events.js +1007 -0
  83. package/lib/review/review-loop.js +1004 -0
  84. package/lib/review/review-polling.js +141 -0
  85. package/lib/review/review-prompts.js +212 -0
  86. package/lib/review/review-state.js +280 -0
  87. package/lib/review/review.js +96 -0
  88. package/lib/tools/backlog.js +680 -0
  89. package/lib/tools/forgejo.js +1585 -0
  90. package/lib/tools/gatekeeper.js +106 -0
  91. package/lib/tools/sessions.js +74 -0
  92. package/lib/tools/setup-review.js +1053 -0
  93. package/package.json +56 -0
  94. package/prompts/act-on-review-verbose.md +20 -0
  95. package/prompts/act-on-review.md +22 -0
  96. package/prompts/draft.md +20 -0
  97. package/prompts/execute.md +24 -0
  98. package/prompts/portfolio.md +30 -0
  99. package/prompts/review-verbose.md +20 -0
  100. package/prompts/review.md +17 -0
  101. package/px.js +236 -0
  102. package/templates/AGENTS-snippet.md +14 -0
  103. package/templates/AGENTS.md.template +34 -0
  104. package/templates/CLAUDE.md.template +27 -0
  105. package/templates/CODEX.md.template +38 -0
  106. package/templates/MISTRAL.md.template +24 -0
  107. package/templates/claude-commands/act-on-review.md +3 -0
  108. package/templates/claude-commands/area-review.md +3 -0
  109. package/templates/claude-commands/draft.md +6 -0
  110. package/templates/claude-commands/execute.md +6 -0
  111. package/templates/claude-commands/integrate.md +4 -0
  112. package/templates/claude-commands/portfolio.md +5 -0
  113. package/templates/claude-commands/review.md +4 -0
  114. package/templates/codex/config.toml +6 -0
  115. package/templates/mission-scaffold.md +39 -0
  116. package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
  117. package/templates/vibe/skills/area-review/SKILL.md +16 -0
  118. package/templates/vibe/skills/draft/SKILL.md +16 -0
  119. package/templates/vibe/skills/execute/SKILL.md +16 -0
  120. package/templates/vibe/skills/integrate/SKILL.md +16 -0
  121. package/templates/vibe/skills/portfolio/SKILL.md +21 -0
  122. package/templates/vibe/skills/review/SKILL.md +16 -0
  123. package/tools/setup-forgejo-docker.sh +84 -0
@@ -0,0 +1,1004 @@
1
+ /**
2
+ * Review Loop Module
3
+ * Extracted from parallix/lib/review.js for task-1201
4
+ * Handles autonomous review loop orchestration.
5
+ */
6
+
7
+ const os = require('os');
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const fmt = require('../core/fmt');
11
+ const { git, run } = require('../core/git');
12
+ const { findMissionDir, resolveWorktree, missionBranchName } = require('../core/mission-utils');
13
+ const { resolveTaskFile, getTaskImplementer, getTaskStatus, enforceTaskAssignee, transitionTask, reportTaskResolution } = require('../tools/backlog');
14
+ const { toVirtual, transitionVirtual } = require('../core/state-map');
15
+ const { getPrStatus, readToken, getLatestReviewForPr, getLatestDispositionForPr, providerAvailable, getComments, postComment, postReview, closePr, resolveReviewUser, isProviderEnabled } = require('./review-adapter');
16
+ const { buildAutonomousReviewMatrix, formatMatrixSummary } = require('../core/runtime-matrix');
17
+ const { buildReviewPrompt, buildActOnReviewPrompt, buildCompactReviewPrompt, buildCompactActOnReviewPrompt } = require('./review-prompts');
18
+ const { ReviewState, readReviewState, writeReviewState, resetReviewState, VALID_PHASES } = require('./review-state');
19
+ const { createEvent, consumeHumanNotes, VALID_EVENT_TYPES } = require('./review-events');
20
+ const { workflowLauncherStatus, startAgent, eligibleAgentsForStep, selectAgent } = require('../agents/agents');
21
+ const { performHandoff } = require('../commands/handoff');
22
+ const { commitSafeMissionArtifacts, rebaseBeforeReviewRound } = require('./rebase');
23
+
24
+ // Import from review-polling module
25
+ const { POLL_TIMEOUT, delay, resolvePollIntervalMs, resolvePollTimeoutMs, formatElapsed, isPollTimeout, pollForReview, pollForDisposition } = require('./review-polling');
26
+ // Import from review-artifacts module
27
+ const { buildMetadataFooter, reviewArtifactPath, resolveArtifactDir, readArtifactFile, deleteArtifactFile, normalizeReviewVerdict, normalizeDisposition, postWorkflowComment, postWorkflowReview, consumeReviewerArtifacts, consumeImplementerArtifacts } = require('./review-artifacts');
28
+ const stats = require('../commands/stats');
29
+ const { resolveStageTelemetry } = require('../agents/stage-telemetry');
30
+
31
+ // Stage telemetry recording is best-effort: a failure must never break the
32
+ // review loop. Token columns are populated only for the codex role (the C2 rule
33
+ // guarantees at most one of implementer/reviewer is codex); other families
34
+ // record honest zeros (task-1251).
35
+ //
36
+ // Codex writes a fresh-counter rollout per round, so we sum total_token_usage
37
+ // across every rollout since the review loop started (`sinceMs`). This is
38
+ // recomputed from the rollout files each round — idempotent across the workflow
39
+ // process being resumed mid-mission — and the per-(mission,stage) upsert leaves
40
+ // the codex role's full multi-round total. `result.telemetry` is used only as a
41
+ // signal that codex ran this round, so the window-sum is attributed to the right
42
+ // role and never double-counted onto the non-codex stage.
43
+ function recordStageStatsSafe(kind, { slug, rootDir, worktree, implementer, reviewer, result, sinceMs, log, error }) {
44
+ let durationMinutes = 0;
45
+ if (result && result.startedAt && result.endedAt) {
46
+ durationMinutes = (Date.parse(result.endedAt) - Date.parse(result.startedAt)) / 60000;
47
+ }
48
+ // Shared with the execute hook (commands/active.js) via stage-telemetry.js:
49
+ // prefer the windowed Codex rollout, else the launcher-attached telemetry so
50
+ // Claude active/review stages record real tokens instead of all-zeros
51
+ // (task-1274), bounded to this stage by sinceMs (task-1285 F6).
52
+ const telemetry = resolveStageTelemetry({ worktree, result, sinceMs });
53
+ try {
54
+ const fn = kind === 'review' ? stats.recordReviewStats : stats.recordActiveStats;
55
+ fn({ slug, rootDir, implementer, reviewer, telemetry, durationMinutes });
56
+ } catch (err) {
57
+ // Best-effort: never escalate to the fatal `error` channel.
58
+ log(fmt.status('WARN', `Could not record ${kind} stats for ${slug}: ${err.message}`));
59
+ }
60
+ }
61
+
62
+ const DEFAULT_MAX_ATTEMPTS = 5;
63
+ const CONTINUE_SKIP_CHECK_TIMEOUT_MS = 10_000;
64
+
65
+ function strictlyLaterIso(earlierIso, nowMs = Date.now()) {
66
+ const earlierMs = Date.parse(earlierIso);
67
+ if (!Number.isFinite(earlierMs)) {
68
+ return new Date(nowMs).toISOString();
69
+ }
70
+ return new Date(Math.max(nowMs, earlierMs + 1)).toISOString();
71
+ }
72
+
73
+ // ============================================================================
74
+ // Pre-review Setup Functions
75
+ // ============================================================================
76
+
77
+ function maybeUpdateGraphifyBeforeReview(rootDir, { commandRunner = run, log = fmt.log.plain } = {}) {
78
+ const updateGraphifyKnowledgeGraph = require('../core/mission-utils').updateGraphifyKnowledgeGraph;
79
+ return updateGraphifyKnowledgeGraph({
80
+ rootDir,
81
+ commandRunner,
82
+ log,
83
+ startMessage: 'Updating graphify knowledge graph...',
84
+ failureHint: 'Continuing without blocking review start.'
85
+ });
86
+ }
87
+
88
+
89
+ // ============================================================================
90
+ // Agent Fallback Handling
91
+ // ============================================================================
92
+
93
+ // If startAgent fell back to a different family after a limit hit, the comments
94
+ // that gate the loop (review outcome / disposition) will be authored by the
95
+ // fallback identity selected by the launcher.
96
+ // This helper detects the fallback, persists the new identity to review state
97
+ // and the Backlog assignee, and returns the identity that should be polled for.
98
+ function applyAgentFallback({
99
+ role,
100
+ original,
101
+ launchResult,
102
+ state,
103
+ slug,
104
+ worktree,
105
+ taskResolution,
106
+ log = fmt.log.plain,
107
+ writeReviewStateFn = writeReviewState,
108
+ enforceTaskAssigneeFn = enforceTaskAssignee
109
+ } = {}) {
110
+ if (!launchResult || !launchResult.agent || launchResult.agent === original) {
111
+ return original;
112
+ }
113
+ const fallback = launchResult.agent;
114
+ log(fmt.status('INFO', `${role} fell back from ${original} to ${fallback}; updating identity before polling.`));
115
+ if (role === 'reviewer') {
116
+ state.reviewer = fallback;
117
+ } else {
118
+ state.implementer = fallback;
119
+ }
120
+ writeReviewStateFn(slug, state, worktree);
121
+ if (role === 'implementer' && taskResolution && taskResolution.ok) {
122
+ if (!enforceTaskAssigneeFn(taskResolution.taskFile, fallback)) {
123
+ log(fmt.status('WARN', `Could not enforce fallback implementer ${fallback} in backlog task.`));
124
+ }
125
+ }
126
+ return fallback;
127
+ }
128
+
129
+ function persistNormalizedPhaseRepair(slug, state, worktree, { log = fmt.log.plain, writeReviewStateFn = writeReviewState } = {}) {
130
+ if (!state || !state.phaseOriginal || VALID_PHASES.includes(state.phaseOriginal)) {
131
+ return;
132
+ }
133
+ log(fmt.status('WARN', `Persisted review phase "${state.phaseOriginal}" is invalid. Repairing to "${state.phase}".`));
134
+ writeReviewStateFn(slug, state, worktree);
135
+ state.phaseOriginal = null;
136
+ }
137
+
138
+ // ============================================================================
139
+ // Main Review Loop
140
+ // ============================================================================
141
+
142
+ async function startReviewLoop(slug, opts = {}) {
143
+ let {
144
+ implementer,
145
+ reviewer,
146
+ focus = 'all',
147
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
148
+ dryRun = false,
149
+ reset = false,
150
+ continue: continueFlag = false,
151
+ isContinue = false,
152
+ verbose = false,
153
+ pollTimeoutSeconds = null,
154
+ worktree: callerWorktree,
155
+ missionPath,
156
+ resetReviewStateFn = resetReviewState,
157
+ maybeUpdateGraphifyBeforeReviewFn = maybeUpdateGraphifyBeforeReview,
158
+ readReviewStateFn = readReviewState,
159
+ resolveTaskFileFn = resolveTaskFile,
160
+ getTaskImplementerFn = getTaskImplementer,
161
+ getTaskStatusFn = getTaskStatus,
162
+ transitionTaskFn = transitionTask,
163
+ toVirtualFn = toVirtual,
164
+ transitionVirtualFn = transitionVirtual,
165
+ workflowLauncherStatusFn = workflowLauncherStatus,
166
+ buildAutonomousReviewMatrixFn = buildAutonomousReviewMatrix,
167
+ formatMatrixSummaryFn = formatMatrixSummary,
168
+ selectAgentFn = selectAgent,
169
+ providerAvailableFn = undefined,
170
+ runFn = run,
171
+ getPrStatusFn = getPrStatus,
172
+ enforceTaskAssigneeFn = enforceTaskAssignee,
173
+ resolveReviewUserFn = undefined,
174
+ forgejoAvailableFn = null,
175
+ resolveForgejoUserFn = null,
176
+ readTokenFn = readToken,
177
+ getCommentsFn = getComments,
178
+ postCommentFn = postComment,
179
+ postReviewFn = postReview,
180
+ writeReviewStateFn = writeReviewState,
181
+ startAgentFn = startAgent,
182
+ pollForReviewFn = pollForReview,
183
+ pollForDispositionFn = pollForDisposition,
184
+ applyAgentFallbackFn = applyAgentFallback,
185
+ buildReviewPromptFn = buildReviewPrompt,
186
+ buildActOnReviewPromptFn = buildActOnReviewPrompt,
187
+ buildCompactReviewPromptFn = buildCompactReviewPrompt,
188
+ buildCompactActOnReviewPromptFn = buildCompactActOnReviewPrompt,
189
+ consumeReviewerArtifactsFn = consumeReviewerArtifacts,
190
+ consumeImplementerArtifactsFn = consumeImplementerArtifacts,
191
+ rebaseBeforeReviewRoundFn = rebaseBeforeReviewRound,
192
+ eligibleAgentsForStepFn = eligibleAgentsForStep,
193
+ performHandoffFn = performHandoff,
194
+ log = fmt.log.plain,
195
+ error = fmt.log.plainError,
196
+ getLatestReviewForPrFn = getLatestReviewForPr,
197
+ getLatestDispositionForPrFn = getLatestDispositionForPr,
198
+ sleepFn = delay,
199
+ exit = process.exit,
200
+ gitFn = git,
201
+ isReviewProviderEnabledFn = undefined,
202
+ legacyIsForgejoReviewEnabledFn = null,
203
+ isForgejoReviewEnabledFn = null
204
+ } = opts;
205
+ let prNumber = null;
206
+ isContinue = Boolean(isContinue || continueFlag);
207
+
208
+ const pollIntervalMs = resolvePollIntervalMs();
209
+ const pollTimeoutMs = resolvePollTimeoutMs(pollTimeoutSeconds);
210
+
211
+ const worktree = callerWorktree || resolveWorktree(slug) || process.cwd();
212
+ const resolvedProviderAvailableFn = providerAvailableFn || forgejoAvailableFn || providerAvailable;
213
+ const resolvedReviewUserFn = resolveReviewUserFn || resolveForgejoUserFn || resolveReviewUser;
214
+ const branch = missionBranchName(slug, worktree);
215
+ // Resolve the artifact directory once so reviewer/implementer artifact reads
216
+ // and writes use the adapter-configured location instead of os.tmpdir().
217
+ const artifactDir = resolveArtifactDir(worktree);
218
+
219
+ // `--mission <path>` override (task-1272 SC7): when the caller points the
220
+ // review loop at a mission contract in a non-standard location, resolve the
221
+ // mission directory via the override and thread the contract file path into
222
+ // the reviewer/implementer prompts so launched agents read it (not the
223
+ // slug-derived default). Absent the flag, `effectiveMissionPath` stays
224
+ // undefined and the prompts fall back to the standard slug-derived path.
225
+ const missionDir = findMissionDir(slug, worktree, { missionPath });
226
+ let effectiveMissionPath;
227
+ if (missionPath && fs.existsSync(missionPath)) {
228
+ effectiveMissionPath = fs.statSync(missionPath).isDirectory()
229
+ ? path.join(missionPath, 'MISSION.md')
230
+ : missionPath;
231
+ log(fmt.status('INFO', `Using mission contract from --mission override: ${effectiveMissionPath}`));
232
+ } else if (missionPath) {
233
+ log(fmt.status('WARN', `--mission path not found: ${missionPath}; falling back to slug-derived mission location${missionDir ? ` (${missionDir})` : ''}.`));
234
+ }
235
+
236
+ const taskResolution = resolveTaskFileFn(slug, worktree);
237
+
238
+ if (!dryRun) {
239
+ maybeUpdateGraphifyBeforeReviewFn(worktree, { commandRunner: runFn, log });
240
+ }
241
+
242
+ // --reset: clear persisted state before starting
243
+ if (reset) {
244
+ if (resetReviewStateFn(slug, worktree)) {
245
+ log(fmt.status('INFO', `Review state reset for ${slug}.`));
246
+ }
247
+ }
248
+
249
+ const agents = eligibleAgentsForStepFn('review');
250
+
251
+ const persisted = readReviewStateFn(slug, worktree);
252
+
253
+ if (!implementer) {
254
+ // Try to resume from persisted state
255
+ if (persisted) {
256
+ implementer = persisted.implementer;
257
+ log(fmt.status('INFO', `Resuming persisted implementer: ${implementer}`));
258
+ } else {
259
+ // Try to resolve from backlog task
260
+ if (taskResolution.ok) {
261
+ implementer = getTaskImplementerFn(taskResolution.taskFile);
262
+ if (implementer) {
263
+ log(fmt.status('INFO', `Auto-derived implementer from backlog task: ${implementer}`));
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ if (!implementer) {
270
+ implementer = 'autonomous';
271
+ log(fmt.status('WARN', `No implementer identity resolved for ${slug}; defaulting to "autonomous"`));
272
+ }
273
+
274
+ if (!taskResolution.ok) {
275
+ reportTaskResolution(taskResolution, slug, error);
276
+ exit(1);
277
+ return;
278
+ }
279
+
280
+ // Validate remote review state only when a review provider is enabled.
281
+ const forgejoEnabledFn = isReviewProviderEnabledFn
282
+ || legacyIsForgejoReviewEnabledFn
283
+ || isForgejoReviewEnabledFn
284
+ || isProviderEnabled;
285
+ const forgejoEnabled = forgejoEnabledFn(worktree);
286
+ if (!dryRun && forgejoEnabled) {
287
+ const forgejoUrl = process.env.FORGEJO_URL || 'http://localhost:3300';
288
+ const bootstrapScript = path.resolve(__dirname, '../../scripts/bootstrap.sh');
289
+ log(fmt.status('INFO', `Checking review-provider availability at ${forgejoUrl}...`));
290
+ if (!await resolvedProviderAvailableFn(forgejoUrl)) {
291
+ error(fmt.status('FAIL', `Review provider not reachable at ${forgejoUrl}`));
292
+ error(' Attempting to bootstrap provider containers...');
293
+ const bootstrapResult = runFn('bash', [bootstrapScript], { stdio: 'inherit' });
294
+ if (bootstrapResult.status === 0) {
295
+ log(fmt.status('INFO', 'Bootstrap succeeded. Continuing with review loop.'));
296
+ } else {
297
+ error(fmt.status('FAIL', 'Bootstrap failed. Please run \'scripts/bootstrap.sh\' manually and retry.'));
298
+ exit(1);
299
+ return;
300
+ }
301
+ } else {
302
+ log(fmt.status('INFO', `Review provider is running and reachable at ${forgejoUrl}.`));
303
+ }
304
+ const pr = getPrStatusFn(branch, worktree);
305
+ if (!pr.exists || pr.state !== 'open') {
306
+ // Check task status to detect pre-review (implementation) phase
307
+ const taskStatus = taskResolution.ok ? getTaskStatusFn(taskResolution.taskFile) : null;
308
+ const virtualStatus = taskStatus ? toVirtualFn(taskStatus) : null;
309
+
310
+ // Implementation phase states: 'active' or any virtual state mapping to it
311
+ const isImplementationPhase = taskStatus === 'active' || virtualStatus === 'active';
312
+
313
+ if (isImplementationPhase) {
314
+ // Task is still in implementation phase - provide guidance, don't hard-fail
315
+ log(fmt.status('INFO', `PR not found for ${branch}. Task is in ${taskStatus} — create the PR first: px review ${slug} --push`));
316
+ return;
317
+ }
318
+
319
+ // Task is in a post-implementation state (review/approved/ready-for-integration)
320
+ // or the status is ambiguous, yet no open PR exists. Rather than dead-ending
321
+ // with manual guidance, self-heal: run the canonical handoff (push branch +
322
+ // create PR, reliable since task-1317), re-check, and continue the loop once a
323
+ // PR exists. Only fall back to guidance when self-heal cannot yield an open PR.
324
+ // Emits `--push` (the command self-heal attempts and the correct manual
325
+ // equivalent) — never the old, frequently-wrong `--submit`.
326
+ const fallbackGuidance = (reason) => {
327
+ error(fmt.status('FAIL', `No open review PR found for ${branch}. Create the PR before starting the review loop.`));
328
+ if (reason) error(` Handoff failure: ${reason}`);
329
+ error(` Run: px review ${slug} --push`);
330
+ };
331
+
332
+ if (dryRun) {
333
+ // Dry-run never self-heals (no agents/side effects); emit guidance and exit.
334
+ fallbackGuidance(null);
335
+ exit(1);
336
+ return;
337
+ }
338
+
339
+ log(fmt.status('INFO', `No open review PR for ${branch} (task in ${taskStatus}) — attempting automatic handoff (px review ${slug} --push)...`));
340
+ const handoff = await performHandoffFn(slug, { forgejoUser: implementer, worktree });
341
+
342
+ if (handoff && handoff.gatekeeperPushedBack) {
343
+ // Mandatory artifacts are missing: don't spin the loop. The gatekeeper
344
+ // kept the task in its current state; surface that and stop.
345
+ error(fmt.status('FAIL', `Handoff blocked for ${branch}: mandatory mission artifacts are missing. Task stays in ${taskStatus}; supply the required artifacts and retry.`));
346
+ exit(1);
347
+ return;
348
+ }
349
+
350
+ if (!handoff || !handoff.ok) {
351
+ fallbackGuidance(handoff && handoff.error ? handoff.error : null);
352
+ exit(1);
353
+ return;
354
+ }
355
+
356
+ // Handoff succeeded — re-check whether an open PR now exists.
357
+ const healedPr = getPrStatusFn(branch, worktree);
358
+ if (!healedPr.exists || healedPr.state !== 'open') {
359
+ fallbackGuidance(null);
360
+ exit(1);
361
+ return;
362
+ }
363
+
364
+ log(fmt.status('INFO', `Self-heal succeeded: review PR #${healedPr.number} confirmed open for ${branch}. Continuing review loop.`));
365
+ prNumber = healedPr.number;
366
+ // Fall through into the normal loop with the recovered PR number.
367
+ } else {
368
+ log(fmt.status('INFO', `Review PR #${pr.number} confirmed open for ${branch}.`));
369
+ prNumber = pr.number;
370
+ }
371
+ } else if (!dryRun && !forgejoEnabled) {
372
+ log(fmt.status('INFO', 'Forgejo validation skipped (review provider is not forgejo). Using workflow-owned review surfaces.'));
373
+ }
374
+
375
+ // Note: The implementer may not be in the agents list (eligible for review step)
376
+ // if its launcher is unavailable. This is OK - we'll use fallback logic for the reviewer.
377
+ // The strict implementer eligibility check was removed to allow fallback reviewer selection (SC 4).
378
+
379
+ // Resolve reviewer after the no-PR/self-heal gate so implementation-phase
380
+ // guidance does not depend on workstation launcher availability.
381
+ let reviewerSource = 'explicit';
382
+ let selectErr = null;
383
+ if (!reviewer) {
384
+ if (persisted && persisted.reviewer) {
385
+ reviewer = persisted.reviewer;
386
+ reviewerSource = 'persisted';
387
+ log(fmt.status('INFO', `Resuming persisted reviewer: ${reviewer} (round ${persisted.round})`));
388
+ } else {
389
+ try {
390
+ reviewer = selectAgentFn('review', { exclude: new Set([implementer]) });
391
+ } catch (err) {
392
+ selectErr = err;
393
+ reviewer = null;
394
+ }
395
+ reviewerSource = 'auto-derived';
396
+ }
397
+ }
398
+
399
+ if (!reviewer) {
400
+ const anyDifferentFamilyRunnable = agents.some(a => a !== implementer && workflowLauncherStatusFn(a).supported);
401
+ const implementerRunnable = agents.includes(implementer) && workflowLauncherStatusFn(implementer).supported;
402
+ if (!anyDifferentFamilyRunnable && implementerRunnable) {
403
+ log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
404
+ log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
405
+ reviewer = implementer;
406
+ reviewerSource = 'single-family-fallback';
407
+ } else if (!anyDifferentFamilyRunnable) {
408
+ if (!forgejoEnabled) {
409
+ const detail = selectErr ? `: ${selectErr.message}` : '';
410
+ reviewer = 'autonomous';
411
+ reviewerSource = 'fallback';
412
+ log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
413
+ // Provider=none mode can complete the loop with workflow-owned artifacts only.
414
+ // Do not hard-fail just because no local reviewer launcher is runnable.
415
+ log(fmt.status('INFO', 'Review provider disabled and no runnable reviewer route available; using autonomous workflow-owned review surfaces.'));
416
+ } else {
417
+ const reason = selectErr ? `: ${selectErr.message}` : '';
418
+ error(fmt.status('FAIL', `No reviewer could be auto-derived${reason}.`));
419
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
420
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => error(` ${line}`));
421
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
422
+ exit(1);
423
+ return;
424
+ }
425
+ } else {
426
+ const detail = selectErr ? `: ${selectErr.message}` : '';
427
+ reviewer = 'autonomous';
428
+ reviewerSource = 'fallback';
429
+ log(fmt.status('WARN', `No reviewer could be auto-derived${detail}; defaulting to "autonomous"`));
430
+ }
431
+ }
432
+
433
+ const willLaunchRounds = maxAttempts >= ((persisted && persisted.round) || 1);
434
+ const resumesInFixingPhase = persisted && persisted.phase === 'fixing' && !dryRun;
435
+
436
+ // Dry-run validates the reviewer identity but only explicit reviewers bypass
437
+ // launcher support checks; auto-derived/persisted reviewers still exercise
438
+ // fallback routing so dry-run logs reflect the real selection path.
439
+ if (reviewerSource === 'fallback') {
440
+ log(fmt.status('INFO', `Reviewer identity defaulted to autonomous; skipping launcher availability check.`));
441
+ } else if (resumesInFixingPhase) {
442
+ log(fmt.status('INFO', `Resuming in fixing phase with reviewer "${reviewer}"; skipping launcher availability check until a new review launch is needed.`));
443
+ } else if (dryRun && reviewerSource === 'explicit') {
444
+ const reviewerStatus = workflowLauncherStatusFn(reviewer);
445
+ const hasInjectedLauncherStatus = workflowLauncherStatusFn !== workflowLauncherStatus;
446
+ if (!agents.includes(reviewer) || (hasInjectedLauncherStatus && !reviewerStatus.supported)) {
447
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
448
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
449
+ if (reason === 'launcher is not available' && reviewerStatus.detail) {
450
+ error(` Looked for: ${reviewerStatus.detail}`);
451
+ }
452
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
453
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => error(` ${line}`));
454
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
455
+ exit(1);
456
+ return;
457
+ }
458
+ } else if (!willLaunchRounds) {
459
+ if (!agents.includes(reviewer)) {
460
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (blocked or unsupported).`));
461
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
462
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => error(` ${line}`));
463
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
464
+ exit(1);
465
+ return;
466
+ }
467
+ } else {
468
+ let reviewerStatus = workflowLauncherStatusFn(reviewer);
469
+ const triedReviewers = new Set();
470
+ while (!agents.includes(reviewer) || !reviewerStatus.supported) {
471
+ triedReviewers.add(reviewer);
472
+ if (reviewerSource === 'explicit') {
473
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
474
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}).`));
475
+ if (reviewerStatus.detail) error(` Looked for: ${reviewerStatus.detail}`);
476
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
477
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => error(` ${line}`));
478
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
479
+ exit(1);
480
+ return;
481
+ }
482
+
483
+ const excludeSet = new Set([...triedReviewers, implementer]);
484
+ let fallback;
485
+ let fallbackStatus;
486
+ try {
487
+ fallback = selectAgentFn('review', { exclude: excludeSet });
488
+ if (excludeSet.has(fallback)) {
489
+ fallback = null;
490
+ fallbackStatus = null;
491
+ } else {
492
+ fallbackStatus = workflowLauncherStatusFn(fallback);
493
+ }
494
+ } catch (err) {
495
+ fallback = null;
496
+ fallbackStatus = null;
497
+ }
498
+
499
+ if (!fallback) {
500
+ const anyDifferentFamilyRunnable = agents.some(a => a !== implementer && workflowLauncherStatusFn(a).supported);
501
+ const implementerRunnable = agents.includes(implementer) && workflowLauncherStatusFn(implementer).supported;
502
+ if (!anyDifferentFamilyRunnable && implementerRunnable) {
503
+ log(fmt.status('WARN', `No supported different-family reviewer found for implementer "${implementer}".`));
504
+ log(fmt.status('WARN', `Single-family fallback: reviewer="${implementer}" (same as implementer) — no different-family agent is runnable or unblocked on this workstation.`));
505
+ reviewer = implementer;
506
+ reviewerSource = 'single-family-fallback';
507
+ break;
508
+ }
509
+ if (dryRun && implementer === 'autonomous') {
510
+ reviewer = 'autonomous';
511
+ reviewerSource = 'fallback';
512
+ log(fmt.status('WARN', `No runnable reviewer launcher available in dry-run; defaulting reviewer identity to "autonomous".`));
513
+ break;
514
+ }
515
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
516
+ error(fmt.status('FAIL', `Unsupported reviewer: "${reviewer}" (${reason}) and no unblocked different-family fallback is available.`));
517
+ if (reviewerStatus.detail) error(` Looked for: ${reviewerStatus.detail}`);
518
+ error('\n' + fmt.status('INFO', 'Full runtime matrix:'));
519
+ formatMatrixSummaryFn(buildAutonomousReviewMatrixFn()).forEach(line => error(` ${line}`));
520
+ error('\n' + fmt.status('FAIL', `No runnable reviewer route for implementer "${implementer}".`));
521
+ exit(1);
522
+ return;
523
+ }
524
+
525
+ const reason = !agents.includes(reviewer) ? 'blocked or unsupported' : 'launcher is not available';
526
+ log(fmt.status('WARN', `Unsupported reviewer: "${reviewer}" (${reason}); trying fallback "${fallback}".`));
527
+ reviewer = fallback;
528
+ reviewerStatus = fallbackStatus;
529
+ reviewerSource = reviewerSource === 'persisted' ? 'persisted-fallback' : 'auto-derived-fallback';
530
+ }
531
+ }
532
+
533
+ log(fmt.status('INFO', `Selected reviewer: ${reviewer} (${reviewerSource})`));
534
+
535
+ // Build the canonical ReviewState instance — either from persisted state or fresh
536
+ const state = persisted && persisted.reviewer === reviewer && persisted.implementer === implementer
537
+ ? ReviewState.from(slug, persisted)
538
+ : new ReviewState(slug, { reviewer, implementer });
539
+ persistNormalizedPhaseRepair(slug, state, worktree, { log, writeReviewStateFn });
540
+
541
+ if (persisted && state.round > 1 && !dryRun) {
542
+ log(fmt.status('INFO', `Resuming review loop from round ${state.round} (${state.phase}).`));
543
+ }
544
+
545
+ log(fmt.status('INFO', `Starting autonomous review loop for mission: ${slug}`));
546
+ log(fmt.status('INFO', `Branch: ${branch}`));
547
+ log(fmt.status('INFO', `Implementer: ${implementer} | Reviewer: ${reviewer} (${reviewerSource})`));
548
+ log(fmt.status('INFO', `Focus: ${focus} | Max attempts: ${maxAttempts}`));
549
+ log(fmt.status('INFO', `Poll interval: ${Math.round(pollIntervalMs / 1000)}s | Poll timeout: ${Math.round(pollTimeoutMs / 1000)}s${verbose ? ' | Verbose: on' : ''}`));
550
+
551
+ if (dryRun) {
552
+ log(fmt.status('DRY-RUN', 'No agents will be launched.'));
553
+ }
554
+
555
+ // Only resolve a provider identity and read a token when a provider is enabled.
556
+ const pollingUser = forgejoEnabled ? resolvedReviewUserFn() : null;
557
+ const token = dryRun || !forgejoEnabled ? null : readTokenFn(pollingUser, { rootDir: worktree });
558
+ const initialRound = state.round;
559
+
560
+ for (let attempt = initialRound; attempt <= maxAttempts; attempt++) {
561
+ log('\n' + fmt.status('INFO', `========== Round ${attempt} / ${maxAttempts} ==========`));
562
+
563
+ // On rounds after the first, advance the state machine
564
+ if (attempt > state.round) {
565
+ state.advanceRound();
566
+ }
567
+
568
+ let reviewState;
569
+
570
+ if (state.phase === 'reviewing') {
571
+ // Check if we can skip reviewer launch
572
+ if (isContinue && attempt === initialRound) {
573
+ if (!dryRun) transitionTaskFn(slug, 'review', { rootDir: worktree, log });
574
+ if (forgejoEnabled) {
575
+ log(fmt.status('INFO', `Round ${attempt}: checking for existing review by ${reviewer} since ${state.startedAt}...`));
576
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
577
+ getLatestReviewForPrFn, sleepFn, intervalMs: 1000, timeoutMs: 2000, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} skip-check`, log
578
+ });
579
+ // Skip-check timeout means no existing review - treat as null to proceed with initial launch
580
+ if (isPollTimeout(reviewState)) {
581
+ reviewState = null;
582
+ }
583
+ } else {
584
+ // When the provider is disabled, use local review state instead of polling.
585
+ log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned review state.`));
586
+ reviewState = null;
587
+ }
588
+ }
589
+
590
+ // Handle reviewState: could be a valid state, null (no existing review or hard failure), or undefined (first time)
591
+ if (dryRun) {
592
+ if (!reviewState) {
593
+ if (reviewer === 'autonomous') {
594
+ log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping dry-run reviewer prompt and using local review artifacts only.`));
595
+ } else {
596
+ log(`\n--- DRY-RUN: reviewer (${reviewer}) prompt ---`);
597
+ log(buildReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer: '{{AGENT_NAME}}' }));
598
+ }
599
+ }
600
+ } else {
601
+ if (!reviewState) {
602
+ // First launch or hard failure (null)
603
+ const rebaseResult = await rebaseBeforeReviewRoundFn(slug, {
604
+ worktree, runFn, log, error,
605
+ taskFile: taskResolution.taskFile,
606
+ gitFn,
607
+ isReviewProviderEnabledFn: forgejoEnabledFn
608
+ });
609
+ if (!rebaseResult.ok) { exit(1); return; }
610
+
611
+ if (!dryRun) transitionTaskFn(slug, 'review', { rootDir: worktree, log });
612
+ state.phase = 'reviewing';
613
+ writeReviewStateFn(slug, state, worktree);
614
+ if (reviewer === 'autonomous' && !forgejoEnabled) {
615
+ log(fmt.status('INFO', `Round ${attempt}: reviewer identity is autonomous; skipping reviewer launch and using local review artifacts only.`));
616
+ } else {
617
+ log(fmt.status('INFO', `Round ${attempt}: launching reviewer (${reviewer})...`));
618
+
619
+ let reviewerLaunchResult;
620
+ try {
621
+ reviewerLaunchResult = await startAgentFn('review', {
622
+ agent: reviewer,
623
+ prompt: (actualReviewer) => buildCompactReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer }),
624
+ worktree, slug, role: 'reviewer', exclude: [implementer]
625
+ });
626
+ } catch (err) {
627
+ error(fmt.status('FAIL', `Could not launch reviewer agent (${reviewer}): ${err.message}`));
628
+ exit(1); return;
629
+ }
630
+
631
+ reviewer = applyAgentFallbackFn({
632
+ role: 'reviewer', original: reviewer, launchResult: reviewerLaunchResult,
633
+ state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
634
+ });
635
+
636
+ // Record review-stage telemetry immediately after the reviewer runs,
637
+ // so the worktree's newest codex rollout is this reviewer's session.
638
+ // Codex sessions are resumed across rounds, so total_token_usage is
639
+ // cumulative for the reviewer role; the per-round upsert overwrites,
640
+ // leaving the final row = the reviewer's full-mission usage.
641
+ recordStageStatsSafe('review', {
642
+ slug, rootDir: worktree, worktree, reviewer, implementer,
643
+ result: reviewerLaunchResult && reviewerLaunchResult.result,
644
+ sinceMs: state.startedAt ? Date.parse(state.startedAt) : 0, log, error
645
+ });
646
+ }
647
+
648
+ const reviewerArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
649
+ worktree,
650
+ tmpDir: artifactDir,
651
+ fallbackToTmp: true,
652
+ readTokenFn,
653
+ getCommentsFn,
654
+ postCommentFn,
655
+ postReviewFn,
656
+ buildMetadataFooterFn: buildMetadataFooter,
657
+ forgejoEnabled,
658
+ log,
659
+ error
660
+ });
661
+ if (reviewerArtifacts.consumed) {
662
+ if (!reviewerArtifacts.ok) { exit(1); return; }
663
+ reviewState = reviewerArtifacts.reviewState;
664
+ }
665
+ if (!reviewState && forgejoEnabled) {
666
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
667
+ getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} review`, log
668
+ });
669
+ }
670
+ }
671
+
672
+ if (isPollTimeout(reviewState)) {
673
+ // Timeout recovery loop for reviewer
674
+ while (state.reviewerRetryCount < 2) {
675
+ state.reviewerRetryCount++;
676
+ writeReviewStateFn(slug, state, worktree);
677
+ const elapsedStr = formatElapsed(Date.now() - new Date(state.startedAt));
678
+ const recoveryPrompt = `RECOVERY: Reviewer timeout after ${elapsedStr}. Please complete the review for ${branch}.`;
679
+ log(fmt.status('INFO', `Round ${attempt}: relaunching reviewer (${reviewer}) with recovery prompt (retry ${state.reviewerRetryCount}/3)...`));
680
+
681
+ let relaunchResult;
682
+ try {
683
+ relaunchResult = await startAgentFn('review', {
684
+ agent: reviewer,
685
+ prompt: (actualReviewer) => buildCompactReviewPromptFn({ reviewer, branch, implementer, focus, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualReviewer }) + '\n\n' + recoveryPrompt,
686
+ worktree, slug, role: 'reviewer', exclude: [implementer]
687
+ });
688
+ } catch (err) {
689
+ error(fmt.status('FAIL', `Could not relaunch reviewer agent (${reviewer}): ${err.message}`));
690
+ exit(1); return;
691
+ }
692
+
693
+ reviewer = applyAgentFallbackFn({
694
+ role: 'reviewer', original: reviewer, launchResult: relaunchResult,
695
+ state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
696
+ });
697
+
698
+ const retryArtifacts = await consumeReviewerArtifactsFn(slug, reviewer, {
699
+ worktree,
700
+ tmpDir: artifactDir,
701
+ fallbackToTmp: true,
702
+ readTokenFn,
703
+ getCommentsFn,
704
+ postCommentFn,
705
+ postReviewFn,
706
+ buildMetadataFooterFn: buildMetadataFooter,
707
+ forgejoEnabled,
708
+ log,
709
+ error
710
+ });
711
+ if (retryArtifacts.consumed) {
712
+ if (!retryArtifacts.ok) { exit(1); return; }
713
+ reviewState = retryArtifacts.reviewState;
714
+ }
715
+ if (!reviewState && forgejoEnabled) {
716
+ reviewState = await pollForReviewFn(prNumber, reviewer, state.startedAt, token, {
717
+ getLatestReviewForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.reviewerRetryCount, verbose, label: `round ${attempt} review retry ${state.reviewerRetryCount}`, log
718
+ });
719
+ }
720
+
721
+ if (!isPollTimeout(reviewState)) break;
722
+ }
723
+
724
+ if (isPollTimeout(reviewState)) {
725
+ log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
726
+ return;
727
+ }
728
+ } else if (!reviewState) {
729
+ error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
730
+ error(' The reviewer agent may have exited without posting to the review PR.');
731
+ exit(1); return;
732
+ }
733
+ }
734
+
735
+ if (dryRun) return;
736
+
737
+ if (!reviewState) {
738
+ error(fmt.status('FAIL', `Reviewer ${reviewer} did not submit a formal review outcome for ${branch}.`));
739
+ error(' The reviewer agent may have exited without posting to the review PR.');
740
+ exit(1); return;
741
+ }
742
+
743
+ if (isPollTimeout(reviewState)) {
744
+ log(fmt.status('INFO', `Autonomous review stopped: excessive reviewer timeout retries`));
745
+ return;
746
+ }
747
+
748
+ log(fmt.status('INFO', `Round ${attempt}: reviewer outcome = ${reviewState}`));
749
+
750
+ if (reviewState === 'APPROVED') {
751
+ state.transitionTo('approved');
752
+ state.disposition = reviewState;
753
+ writeReviewStateFn(slug, state, worktree);
754
+ log(fmt.status('PASS', 'Autonomous review stopped: reviewer approved the PR. Hand off to human review/integration.'));
755
+ transitionVirtualFn(transitionTaskFn, slug, 'approved', { rootDir: worktree, log });
756
+ return;
757
+ }
758
+ state.transitionTo('fixing');
759
+ state.disposition = reviewState;
760
+ } else {
761
+ // Resume in fixing phase, need reviewState
762
+ if (forgejoEnabled) {
763
+ const latestReview = await getLatestReviewForPrFn(prNumber, reviewer, state.startedAt, token);
764
+ reviewState = latestReview ? latestReview.state : null;
765
+ if (!reviewState) {
766
+ // Restore retry path: missing review on resume is recoverable
767
+ // (e.g., reviewer identity changed, state is stale, or artifact not yet
768
+ // discoverable). Treat as request-changes so the fixing phase proceeds
769
+ // and can re-trigger the review on the next attempt.
770
+ log(fmt.status('WARN', `No review found for ${reviewer} since ${state.startedAt}; treating as request-changes and proceeding to fixing phase.`));
771
+ reviewState = 'request-changes';
772
+ }
773
+ } else {
774
+ // When the provider is disabled, use persisted review state.
775
+ reviewState = state.disposition || null;
776
+ if (!reviewState) {
777
+ // Restore retry path: missing local review state on resume is recoverable.
778
+ // Treat as request-changes so the fixing phase proceeds.
779
+ log(fmt.status('WARN', `No local review state found for ${reviewer}; treating as request-changes and proceeding to fixing phase.`));
780
+ reviewState = 'request-changes';
781
+ }
782
+ }
783
+ log(fmt.status('INFO', `Round ${attempt}: resuming in fixing phase with review outcome = ${reviewState}`));
784
+ }
785
+
786
+ // Fixing phase
787
+ let disposition;
788
+ let reLaunch;
789
+ let sinceIso = state.startedAt;
790
+ if (isContinue && attempt === state.round) {
791
+ if (forgejoEnabled) {
792
+ log(fmt.status('INFO', `Round ${attempt}: checking for existing disposition by ${implementer} since ${state.startedAt}...`));
793
+ disposition = await pollForDispositionFn(prNumber, implementer, state.startedAt, token, {
794
+ getLatestDispositionForPrFn, sleepFn, intervalMs: 1000, timeoutMs: CONTINUE_SKIP_CHECK_TIMEOUT_MS, verbose, label: `round ${attempt} skip-check`, log
795
+ });
796
+ // Skip-check timeout means no existing disposition - treat as null to proceed with initial launch
797
+ if (isPollTimeout(disposition)) {
798
+ disposition = null;
799
+ } else if (isContinue && (disposition === 'BLOCKED' || disposition === 'PARKED')) {
800
+ // Stale BLOCKED/PARKED from a previous round: re-launch the implementer
801
+ // so they can post a fresh disposition reflecting the current state of the branch.
802
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Re-launching implementer to assess whether blocker is resolved...`));
803
+ reLaunch = true;
804
+ // Capture a fresh sinceIso so the post-relaunch poll doesn't
805
+ // immediately return the same stale disposition. Use a strictly
806
+ // newer timestamp than the persisted round start to avoid same-ms
807
+ // collisions during fast local test runs.
808
+ sinceIso = strictlyLaterIso(state.startedAt);
809
+ disposition = null;
810
+ }
811
+ } else {
812
+ // When the provider is disabled, use local review state.
813
+ log(fmt.status('INFO', `Round ${attempt}: review provider disabled; using workflow-owned disposition state.`));
814
+ disposition = null;
815
+ }
816
+ }
817
+
818
+ if (!disposition) {
819
+ // First launch or re-launch after stale BLOCKED/PARKED
820
+ if (dryRun) {
821
+ log(`\n--- DRY-RUN: implementer (${implementer}) act-on-review prompt ---`);
822
+ log(buildActOnReviewPromptFn({ implementer, branch, attempt, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer: '{{AGENT_NAME}}' }));
823
+ if (reLaunch) {
824
+ log(fmt.status('INFO', `Round ${attempt}: stale BLOCKED/PARKED disposition replaced by fresh implementer action.`));
825
+ }
826
+ return;
827
+ }
828
+
829
+ writeReviewStateFn(slug, state, worktree);
830
+ transitionTaskFn(slug, 'active', { implementer, rootDir: worktree, log });
831
+ if (implementer === 'autonomous' && !forgejoEnabled) {
832
+ log(fmt.status('INFO', `Round ${attempt}: implementer identity is autonomous; skipping implementer launch and using local review artifacts only.`));
833
+ } else {
834
+ log(fmt.status('INFO', `Round ${attempt}: launching implementer (${implementer}) for act-on-review...`));
835
+
836
+ let implementerLaunchResult;
837
+ try {
838
+ implementerLaunchResult = await startAgentFn('act-on-review', {
839
+ agent: implementer,
840
+ prompt: (actualImplementer) => buildCompactActOnReviewPromptFn({ implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer }),
841
+ worktree, slug, role: 'implementer', exclude: [reviewer]
842
+ });
843
+ } catch (err) {
844
+ error(fmt.status('FAIL', `Could not launch implementer agent (${implementer}): ${err.message}`));
845
+ exit(1); return;
846
+ }
847
+
848
+ implementer = applyAgentFallbackFn({
849
+ role: 'implementer', original: implementer, launchResult: implementerLaunchResult,
850
+ state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
851
+ });
852
+
853
+ // Record active-stage telemetry immediately after the implementer runs.
854
+ // Same cumulative-session semantics as the review stage above; the final
855
+ // round's upsert leaves the implementer's full-mission usage.
856
+ recordStageStatsSafe('active', {
857
+ slug, rootDir: worktree, worktree, implementer, reviewer,
858
+ result: implementerLaunchResult && implementerLaunchResult.result,
859
+ sinceMs: state.startedAt ? Date.parse(state.startedAt) : 0, log, error
860
+ });
861
+ }
862
+
863
+ const implementerArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
864
+ worktree,
865
+ tmpDir: artifactDir,
866
+ fallbackToTmp: true,
867
+ readTokenFn,
868
+ getCommentsFn,
869
+ postCommentFn,
870
+ buildMetadataFooterFn: buildMetadataFooter,
871
+ forgejoEnabled,
872
+ log,
873
+ error
874
+ });
875
+ if (implementerArtifacts.consumed) {
876
+ if (!implementerArtifacts.ok) { exit(1); return; }
877
+ disposition = implementerArtifacts.disposition;
878
+ }
879
+ if (!disposition && forgejoEnabled) {
880
+ disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
881
+ getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: 0, verbose, label: `round ${attempt} disposition`, log
882
+ });
883
+ }
884
+
885
+ if (isPollTimeout(disposition)) {
886
+ // Timeout recovery loop for implementer
887
+ while (state.implementerRetryCount < 2) {
888
+ state.implementerRetryCount++;
889
+ writeReviewStateFn(slug, state, worktree);
890
+ const elapsedStr = formatElapsed(Date.now() - new Date(state.startedAt));
891
+ const recoveryPrompt = `RECOVERY: Implementer disposition timeout after ${elapsedStr}. Please provide a disposition (PUSHBACK_ALL, BLOCKED, PARKED, or continue with fixes) for ${branch}.`;
892
+ log(fmt.status('INFO', `Round ${attempt}: relaunching implementer (${implementer}) with recovery prompt (retry ${state.implementerRetryCount}/3)...`));
893
+
894
+ let relaunchResult;
895
+ try {
896
+ relaunchResult = await startAgentFn('act-on-review', {
897
+ agent: implementer,
898
+ prompt: (actualImplementer) => buildCompactActOnReviewPromptFn({ implementer, branch, attempt, reviewOutcome: reviewState, repoRoot: worktree, missionPath: effectiveMissionPath, actualImplementer }) + '\n\n' + recoveryPrompt,
899
+ worktree, slug, role: 'implementer', exclude: [reviewer]
900
+ });
901
+ } catch (err) {
902
+ error(fmt.status('FAIL', `Could not relaunch implementer agent (${implementer}): ${err.message}`));
903
+ exit(1); return;
904
+ }
905
+
906
+ implementer = applyAgentFallbackFn({
907
+ role: 'implementer', original: implementer, launchResult: relaunchResult,
908
+ state, slug, worktree, taskResolution, log, writeReviewStateFn, enforceTaskAssigneeFn
909
+ });
910
+
911
+ const retryArtifacts = await consumeImplementerArtifactsFn(slug, implementer, {
912
+ worktree,
913
+ tmpDir: artifactDir,
914
+ fallbackToTmp: true,
915
+ readTokenFn,
916
+ getCommentsFn,
917
+ postCommentFn,
918
+ buildMetadataFooterFn: buildMetadataFooter,
919
+ forgejoEnabled,
920
+ log,
921
+ error
922
+ });
923
+ if (retryArtifacts.consumed) {
924
+ if (!retryArtifacts.ok) { exit(1); return; }
925
+ disposition = retryArtifacts.disposition;
926
+ }
927
+ if (!disposition && forgejoEnabled) {
928
+ disposition = await pollForDispositionFn(prNumber, implementer, sinceIso, token, {
929
+ getLatestDispositionForPrFn, sleepFn, intervalMs: pollIntervalMs, timeoutMs: pollTimeoutMs, retryCount: state.implementerRetryCount, verbose, label: `round ${attempt} disposition retry ${state.implementerRetryCount}`, log
930
+ });
931
+ }
932
+
933
+ if (!isPollTimeout(disposition)) break;
934
+ }
935
+
936
+ if (isPollTimeout(disposition)) {
937
+ writeReviewStateFn(slug, state, worktree);
938
+ log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
939
+ return;
940
+ }
941
+ } else if (!disposition) {
942
+ error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
943
+ exit(1); return;
944
+ }
945
+
946
+ // Suppress the duplicate message after re-launch
947
+ reLaunch = null;
948
+ } else if (reLaunch) {
949
+ // Re-launch path: stale BLOCKED/PARKED was replaced by fresh implementation.
950
+ // The disposition was set by the re-launch above (lines 767-785).
951
+ // Fall through to disposition handling below.
952
+ } else {
953
+ // Skip-check found an existing disposition that is not BLOCKED/PARKED (or reLaunch flag).
954
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition found (${disposition}). Skipping implementer launch.`));
955
+ }
956
+
957
+ if (!disposition) {
958
+ error(fmt.status('FAIL', `Implementer ${implementer} did not post an autonomous review disposition comment.`));
959
+ exit(1); return;
960
+ }
961
+
962
+ if (isPollTimeout(disposition)) {
963
+ writeReviewStateFn(slug, state, worktree);
964
+ log(fmt.status('INFO', `Autonomous review stopped: excessive implementer timeout retries`));
965
+ return;
966
+ }
967
+
968
+ log(fmt.status('INFO', `Round ${attempt}: implementer disposition = ${disposition}`));
969
+
970
+ if (disposition === 'PUSHBACK_ALL') {
971
+ state.disposition = disposition;
972
+ writeReviewStateFn(slug, state, worktree);
973
+ log(fmt.status('INFO', 'Autonomous review stopped: implementer pushed back on all remaining comments. Hand off to human review.'));
974
+ return;
975
+ }
976
+
977
+ if (disposition === 'BLOCKED' || disposition === 'PARKED') {
978
+ state.disposition = disposition;
979
+ writeReviewStateFn(slug, state, worktree);
980
+ log(fmt.status('INFO', `Autonomous review stopped: implementer reported ${disposition}. Hand off to human review.`));
981
+ return;
982
+ }
983
+
984
+ state.disposition = disposition;
985
+ try { state.transitionTo('reviewing'); } catch (_) {}
986
+ writeReviewStateFn(slug, state, worktree);
987
+ log(fmt.status('INFO', `Round ${attempt}: implementer made changes. Continuing to round ${attempt + 1}.`));
988
+ }
989
+
990
+ log(fmt.status('INFO', `Autonomous review stopped: reached ${maxAttempts} attempts. Hand off to human review.`));
991
+ }
992
+
993
+ // ============================================================================
994
+ // Module Exports
995
+ // ============================================================================
996
+
997
+ module.exports = {
998
+ maybeUpdateGraphifyBeforeReview,
999
+ commitSafeMissionArtifacts,
1000
+ rebaseBeforeReviewRound,
1001
+ applyAgentFallback,
1002
+ persistNormalizedPhaseRepair,
1003
+ startReviewLoop
1004
+ };