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