codex-orchestrator 0.1.39 → 0.1.41

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 (90) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +17 -0
  3. package/dist/src/cli.js +9 -0
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/codex/command-adapter.d.ts +5 -0
  6. package/dist/src/codex/command-adapter.d.ts.map +1 -1
  7. package/dist/src/codex/command-adapter.js +23 -6
  8. package/dist/src/codex/command-adapter.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +12 -2
  10. package/dist/src/config/schema.d.ts.map +1 -1
  11. package/dist/src/config/schema.js +70 -3
  12. package/dist/src/config/schema.js.map +1 -1
  13. package/dist/src/git/worktree.d.ts +3 -1
  14. package/dist/src/git/worktree.d.ts.map +1 -1
  15. package/dist/src/git/worktree.js +51 -17
  16. package/dist/src/git/worktree.js.map +1 -1
  17. package/dist/src/index.d.ts +2 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +1 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/runner/auto-visual-proof-command.d.ts.map +1 -1
  22. package/dist/src/runner/auto-visual-proof-command.js +6 -12
  23. package/dist/src/runner/auto-visual-proof-command.js.map +1 -1
  24. package/dist/src/runner/command-utils.d.ts +6 -2
  25. package/dist/src/runner/command-utils.d.ts.map +1 -1
  26. package/dist/src/runner/command-utils.js +39 -2
  27. package/dist/src/runner/command-utils.js.map +1 -1
  28. package/dist/src/runner/completion-report.d.ts +21 -5
  29. package/dist/src/runner/completion-report.d.ts.map +1 -1
  30. package/dist/src/runner/completion-report.js +29 -0
  31. package/dist/src/runner/completion-report.js.map +1 -1
  32. package/dist/src/runner/daemon-command.d.ts +2 -0
  33. package/dist/src/runner/daemon-command.d.ts.map +1 -1
  34. package/dist/src/runner/daemon-command.js +13 -0
  35. package/dist/src/runner/daemon-command.js.map +1 -1
  36. package/dist/src/runner/durable-run-summary.d.ts +31 -0
  37. package/dist/src/runner/durable-run-summary.d.ts.map +1 -1
  38. package/dist/src/runner/durable-run-summary.js +99 -1
  39. package/dist/src/runner/durable-run-summary.js.map +1 -1
  40. package/dist/src/runner/handoff-evidence.d.ts +6 -7
  41. package/dist/src/runner/handoff-evidence.d.ts.map +1 -1
  42. package/dist/src/runner/handoff-evidence.js +23 -2
  43. package/dist/src/runner/handoff-evidence.js.map +1 -1
  44. package/dist/src/runner/hygiene-cleanup.d.ts +15 -0
  45. package/dist/src/runner/hygiene-cleanup.d.ts.map +1 -0
  46. package/dist/src/runner/hygiene-cleanup.js +25 -0
  47. package/dist/src/runner/hygiene-cleanup.js.map +1 -0
  48. package/dist/src/runner/issue-tree.d.ts +1 -1
  49. package/dist/src/runner/issue-tree.d.ts.map +1 -1
  50. package/dist/src/runner/issue-tree.js +15 -6
  51. package/dist/src/runner/issue-tree.js.map +1 -1
  52. package/dist/src/runner/local-execution-session.d.ts +1 -5
  53. package/dist/src/runner/local-execution-session.d.ts.map +1 -1
  54. package/dist/src/runner/local-execution-session.js +20 -2
  55. package/dist/src/runner/local-execution-session.js.map +1 -1
  56. package/dist/src/runner/plan-auto-command.d.ts +3 -0
  57. package/dist/src/runner/plan-auto-command.d.ts.map +1 -1
  58. package/dist/src/runner/plan-auto-command.js +267 -97
  59. package/dist/src/runner/plan-auto-command.js.map +1 -1
  60. package/dist/src/runner/plan-auto-recovery.d.ts +83 -0
  61. package/dist/src/runner/plan-auto-recovery.d.ts.map +1 -0
  62. package/dist/src/runner/plan-auto-recovery.js +303 -0
  63. package/dist/src/runner/plan-auto-recovery.js.map +1 -0
  64. package/dist/src/runner/prompt.d.ts +1 -0
  65. package/dist/src/runner/prompt.d.ts.map +1 -1
  66. package/dist/src/runner/prompt.js +5 -2
  67. package/dist/src/runner/prompt.js.map +1 -1
  68. package/dist/src/runner/review-gate-policy.d.ts +15 -1
  69. package/dist/src/runner/review-gate-policy.d.ts.map +1 -1
  70. package/dist/src/runner/review-gate-policy.js +101 -28
  71. package/dist/src/runner/review-gate-policy.js.map +1 -1
  72. package/dist/src/runner/rework-policy.d.ts +33 -2
  73. package/dist/src/runner/rework-policy.d.ts.map +1 -1
  74. package/dist/src/runner/rework-policy.js +84 -14
  75. package/dist/src/runner/rework-policy.js.map +1 -1
  76. package/dist/src/runner/scoped-auto-command.d.ts +3 -1
  77. package/dist/src/runner/scoped-auto-command.d.ts.map +1 -1
  78. package/dist/src/runner/scoped-auto-command.js +52 -10
  79. package/dist/src/runner/scoped-auto-command.js.map +1 -1
  80. package/dist/src/runner/scoped-recovery.d.ts +13 -0
  81. package/dist/src/runner/scoped-recovery.d.ts.map +1 -1
  82. package/dist/src/runner/scoped-recovery.js +16 -14
  83. package/dist/src/runner/scoped-recovery.js.map +1 -1
  84. package/dist/src/setup/project-config.d.ts.map +1 -1
  85. package/dist/src/setup/project-config.js +14 -2
  86. package/dist/src/setup/project-config.js.map +1 -1
  87. package/dist/src/setup/setup-command.js +1 -0
  88. package/dist/src/setup/setup-command.js.map +1 -1
  89. package/docs/deep-dive.md +48 -1
  90. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises';
2
+ import { hostname as osHostname } from 'node:os';
2
3
  import { dirname, join, resolve } from 'node:path';
3
4
  import { CodexCommandAdapter } from '../codex/command-adapter.js';
4
5
  import { resolveBaseBranch } from '../git/base-branch.js';
@@ -18,8 +19,9 @@ import { collectExecutableChildBatches, persistAutonomousChildNode, readAutonomo
18
19
  import { runImplementationPublishabilityCheck } from './local-execution-session.js';
19
20
  import { RunnerLifecycleEventStore } from './lifecycle-events.js';
20
21
  import { RunnerStateStore } from './local-state.js';
22
+ import { classifyPlanAutoBlockedChildRecovery, classifyPlanAutoCompletedChildRecovery, classifyPlanAutoParentRecovery, } from './plan-auto-recovery.js';
21
23
  import { buildIssueTreeChildPrompt, buildPlanAutoPrompt, sessionPromptPath, sessionReportPath, writeDurablePrompt, } from './prompt.js';
22
- import { maxReworkAttemptsForReasons, shouldRequestImplementationRework } from './rework-policy.js';
24
+ import { decideImplementationRework } from './rework-policy.js';
23
25
  import { evaluateParentRiskRoutingGate } from './review-gates.js';
24
26
  import { buildBlockedHandoffEvidence, buildPromotionAsBlockedHandoffEvidence, buildReviewReadyHandoffEvidence, } from './runner-handoff-decision.js';
25
27
  import { sessionLogPath } from './run-log.js';
@@ -28,11 +30,13 @@ class ChildExecutionBlockedError extends Error {
28
30
  durableRunSummary;
29
31
  freshContextReview;
30
32
  acceptanceProof;
31
- constructor(message, durableRunSummary, freshContextReview, acceptanceProof) {
33
+ reworkAttempts;
34
+ constructor(message, durableRunSummary, freshContextReview, acceptanceProof, reworkAttempts) {
32
35
  super(message);
33
36
  this.durableRunSummary = durableRunSummary;
34
37
  this.freshContextReview = freshContextReview;
35
38
  this.acceptanceProof = acceptanceProof;
39
+ this.reworkAttempts = reworkAttempts;
36
40
  }
37
41
  }
38
42
  export async function runPlanAutoCommand(options) {
@@ -49,16 +53,36 @@ export async function runPlanAutoCommand(options) {
49
53
  if (!parentIssue) {
50
54
  throw new Error(`Issue #${options.issueNumber} was not found`);
51
55
  }
56
+ const branchName = renderBranchTemplate(config.branches.issueTree, { parentIssueNumber: options.issueNumber });
57
+ const worktreePath = join(targetRoot, config.runner.workspaceRoot, `tree-${options.issueNumber}`);
58
+ const store = new RunnerStateStore(targetRoot, config);
59
+ const parentRecovery = await classifyPlanAutoParentRecovery({
60
+ targetRoot,
61
+ config,
62
+ parentIssue,
63
+ branchName,
64
+ worktreePath,
65
+ baseSha: resolvedBase.sha,
66
+ state: await store.load(),
67
+ git,
68
+ now,
69
+ hostname: options.hostname,
70
+ processProbe: options.processProbe,
71
+ });
52
72
  const decision = discoverIssueWork([parentIssue], config)[0];
53
- if (!decision || decision.kind !== 'eligible' || decision.mode !== 'plan-parent') {
73
+ const parentLabels = new Set(parentIssue.labels.map((label) => label.name));
74
+ const alreadyRunningPlanParent = decision?.kind === 'skipped'
75
+ && decision.reasonCode === 'already-running'
76
+ && parentIssue.state === 'OPEN'
77
+ && parentLabels.has(config.github.labels.planAuto.name)
78
+ && !parentLabels.has(config.github.labels.child.name);
79
+ if (!decision || !((decision.kind === 'eligible' && decision.mode === 'plan-parent') || alreadyRunningPlanParent)) {
54
80
  const reason = decision?.kind === 'skipped' ? decision.reason : 'not agent:plan-auto';
55
81
  throw new Error(`Issue #${options.issueNumber} is not eligible for agent:plan-auto planning: ${reason}`);
56
82
  }
57
83
  const workflowPrompts = await readPlanWorkflowPrompts(targetRoot, config);
58
84
  const issueTreeWorkflowPrompt = await readIssueTreeWorkflowPrompt(targetRoot, config);
59
85
  const acceptanceProofWorkflowPrompt = await readAcceptanceProofWorkflowPrompt(targetRoot, config);
60
- const branchName = renderBranchTemplate(config.branches.issueTree, { parentIssueNumber: options.issueNumber });
61
- const worktreePath = join(targetRoot, config.runner.workspaceRoot, `tree-${options.issueNumber}`);
62
86
  let promptPath = '';
63
87
  let reportPath = '';
64
88
  let logPath = '';
@@ -66,19 +90,38 @@ export async function runPlanAutoCommand(options) {
66
90
  let parentSnapshotPath = '';
67
91
  const childIssues = [];
68
92
  let pullRequest;
69
- let store;
70
93
  const executionResults = [];
71
94
  const events = new RunnerLifecycleEventStore(targetRoot, config);
72
95
  const buildBase = () => baseResult(options.issueNumber, branchName, worktreePath, promptPath, reportPath, logPath, childIssues, sessionId, parentSnapshotPath);
73
- await claimIssue(issueAdapter, config, options.issueNumber, 'plan-parent', now);
96
+ if (parentRecovery.kind === 'hard-block') {
97
+ return finishPlanBlocked(issueAdapter, config, events, buildBase(), [parentRecovery.reason], childIssues, parentRecovery.marker);
98
+ }
99
+ if (alreadyRunningPlanParent && parentRecovery.kind !== 'resume-parent') {
100
+ return finishPlanBlocked(issueAdapter, config, events, buildBase(), ['parent recovery requires runner-owned metadata for running parent'], childIssues, `plan-auto-recovery-blocked parent=${options.issueNumber} reason=missing-runner-owned-metadata`);
101
+ }
102
+ if (!alreadyRunningPlanParent) {
103
+ await claimIssue(issueAdapter, config, options.issueNumber, 'plan-parent', now);
104
+ }
74
105
  try {
75
- await git.createIssueWorktree({
76
- targetRoot,
77
- workspacePath: worktreePath,
78
- branchName,
79
- baseBranch: resolvedBase.sha,
80
- requiredBaseSha: resolvedBase.sha,
81
- });
106
+ if (parentRecovery.kind === 'resume-parent') {
107
+ await git.ensureIssueWorktree({
108
+ targetRoot,
109
+ workspacePath: worktreePath,
110
+ branchName,
111
+ baseBranch: resolvedBase.sha,
112
+ requiredBaseSha: resolvedBase.sha,
113
+ allowResume: true,
114
+ });
115
+ }
116
+ else {
117
+ await git.createIssueWorktree({
118
+ targetRoot,
119
+ workspacePath: worktreePath,
120
+ branchName,
121
+ baseBranch: resolvedBase.sha,
122
+ requiredBaseSha: resolvedBase.sha,
123
+ });
124
+ }
82
125
  sessionId = `plan-${options.issueNumber}-${formatSessionTimestamp(now)}`;
83
126
  promptPath = sessionPromptPath({ targetRoot, config, issueNumber: options.issueNumber, sessionId });
84
127
  reportPath = sessionReportPath({ targetRoot, config, issueNumber: options.issueNumber, sessionId });
@@ -130,7 +173,6 @@ export async function runPlanAutoCommand(options) {
130
173
  summary: 'Starting parent planning Codex session.',
131
174
  artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshot.path),
132
175
  });
133
- store = new RunnerStateStore(targetRoot, config);
134
176
  await store.upsertRun({
135
177
  issueNumber: options.issueNumber,
136
178
  mode: 'plan-parent',
@@ -143,6 +185,10 @@ export async function runPlanAutoCommand(options) {
143
185
  retryCount: 0,
144
186
  createdAt: now.toISOString(),
145
187
  updatedAt: now.toISOString(),
188
+ ownerPid: process.pid,
189
+ host: (options.hostname ?? osHostname)(),
190
+ leaseUpdatedAt: now.toISOString(),
191
+ baseSha: resolvedBase.sha,
146
192
  });
147
193
  const beforeHead = await git.getHead(worktreePath);
148
194
  let codexResult;
@@ -196,7 +242,60 @@ export async function runPlanAutoCommand(options) {
196
242
  childIssues.push(persisted);
197
243
  }
198
244
  const childNodes = await readAutonomousChildNodes(issueAdapter, config, options.issueNumber, childIssues);
199
- const batches = collectExecutableChildBatches(childNodes, config);
245
+ const childRecoveryState = await store.load();
246
+ const childRecoveryDecisions = await Promise.all(childNodes.map(async (child) => {
247
+ const completed = await classifyPlanAutoCompletedChildRecovery({
248
+ targetRoot,
249
+ config,
250
+ parentIssueNumber: options.issueNumber,
251
+ parentBranchName: branchName,
252
+ child,
253
+ state: childRecoveryState,
254
+ git,
255
+ });
256
+ return completed.kind === 'execute-child'
257
+ ? classifyPlanAutoBlockedChildRecovery({
258
+ targetRoot,
259
+ config,
260
+ parentIssueNumber: options.issueNumber,
261
+ child,
262
+ state: childRecoveryState,
263
+ git,
264
+ })
265
+ : completed;
266
+ }));
267
+ const recoveredStableIds = [];
268
+ const resumableBlockedStableIds = [];
269
+ const childRecoveryByStableId = new Map();
270
+ for (const decision of childRecoveryDecisions) {
271
+ if (decision.kind === 'hard-block') {
272
+ return finishPlanBlocked(issueAdapter, config, events, buildBase(), [decision.reason], childIssues, decision.marker);
273
+ }
274
+ if (decision.kind === 'recovered-completed-child') {
275
+ recoveredStableIds.push(decision.child.metadata.stableId);
276
+ executionResults.push({
277
+ child: decision.child,
278
+ branchName: decision.branchName,
279
+ worktreePath: decision.worktreePath,
280
+ promptPath: decision.promptPath,
281
+ reportPath: decision.reportPath,
282
+ logPath: decision.logPath,
283
+ changedFiles: decision.changedFiles,
284
+ validation: decision.validation,
285
+ artifacts: [],
286
+ commits: [],
287
+ skippedChecks: decision.skippedChecks,
288
+ residualRisks: decision.residualRisks,
289
+ durableRunSummary: decision.durableRunSummary,
290
+ recovered: true,
291
+ });
292
+ }
293
+ else if (decision.kind === 'resume-child-rework') {
294
+ resumableBlockedStableIds.push(decision.child.metadata.stableId);
295
+ childRecoveryByStableId.set(decision.child.metadata.stableId, decision);
296
+ }
297
+ }
298
+ const batches = collectExecutableChildBatches(childNodes, config, recoveredStableIds, resumableBlockedStableIds);
200
299
  if (!batches.ok) {
201
300
  return finishPlanBlocked(issueAdapter, config, events, base, batches.errors, childIssues);
202
301
  }
@@ -216,6 +315,7 @@ export async function runPlanAutoCommand(options) {
216
315
  acceptanceProofWorkflowPrompt,
217
316
  now,
218
317
  events,
318
+ recovery: childRecoveryByStableId.get(child.metadata.stableId),
219
319
  })));
220
320
  const failures = [];
221
321
  for (const [index, result] of settled.entries()) {
@@ -266,7 +366,7 @@ export async function runPlanAutoCommand(options) {
266
366
  executionResults.push(childResult);
267
367
  }
268
368
  }
269
- const finalValidation = await runConfiguredChecks(config, worktreePath, shellExecutor, []);
369
+ const finalValidation = await runConfiguredChecks(config, worktreePath, shellExecutor, [], { phase: 'parent-integration' });
270
370
  const failedFinalValidation = finalValidation.filter((line) => line.status === 'failed');
271
371
  if (failedFinalValidation.length > 0) {
272
372
  await blockCompletedChildren(issueAdapter, config, store, options.issueNumber, executionResults, 'Parent integration configured checks failed before publication.', worktreePath);
@@ -331,7 +431,7 @@ export async function runPlanAutoCommand(options) {
331
431
  if (pullRequest) {
332
432
  throw new Error(`Issue-tree execution failed after draft PR creation (${pullRequest.url}); not marking parent blocked: ${message}`);
333
433
  }
334
- if (store && executionResults.length > 0) {
434
+ if (executionResults.length > 0) {
335
435
  await blockCompletedChildren(issueAdapter, config, store, options.issueNumber, executionResults, `Parent publication failed before review handoff: ${message}`, worktreePath);
336
436
  }
337
437
  return finishPlanBlocked(issueAdapter, config, events, buildBase(), [message], childIssues);
@@ -379,77 +479,76 @@ async function executeChild(input) {
379
479
  const childIssueNumber = input.child.issue.number;
380
480
  const branchName = `codex/tree-${input.parentIssue.number}-issue-${childIssueNumber}`;
381
481
  const worktreePath = join(input.targetRoot, input.config.runner.workspaceRoot, `tree-${input.parentIssue.number}-issue-${childIssueNumber}`);
382
- const sessionId = `tree-${input.parentIssue.number}-issue-${childIssueNumber}-${formatSessionTimestamp(input.now)}`;
383
- const promptPath = sessionPromptPath({
384
- targetRoot: input.targetRoot,
385
- config: input.config,
386
- issueNumber: childIssueNumber,
387
- sessionId,
388
- });
389
- const reportPath = sessionReportPath({
390
- targetRoot: input.targetRoot,
391
- config: input.config,
392
- issueNumber: childIssueNumber,
393
- sessionId,
394
- });
395
- const logPath = sessionLogPath({
396
- targetRoot: input.targetRoot,
397
- config: input.config,
398
- issueNumber: childIssueNumber,
399
- sessionId,
400
- });
401
- const isolatedHomePath = sessionCodexHomePath({ targetRoot: input.targetRoot, sessionId });
402
482
  const store = new RunnerStateStore(input.targetRoot, input.config);
403
- await input.git.createIssueWorktree({
404
- targetRoot: input.targetRoot,
405
- workspacePath: worktreePath,
406
- branchName,
407
- baseBranch: input.parentBranchName,
408
- });
409
- await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
410
- await input.issueAdapter.postComment(childIssueNumber, `codex-orchestrator: claimed #${childIssueNumber} for tree-child autonomous work under #${input.parentIssue.number} at ${input.now.toISOString()}.`);
411
- await mkdir(dirname(reportPath), { recursive: true });
412
- await mkdir(isolatedHomePath, { recursive: true });
413
- const promptText = buildIssueTreeChildPrompt({
414
- parentIssue: input.parentIssue,
415
- childIssue: input.child.issue,
416
- config: input.config,
417
- workflowPromptText: input.issueTreeWorkflowPrompt,
418
- childMetadata: input.child.metadata,
419
- dependencyIssues: input.dependencyIssues,
420
- promptPath,
421
- reportPath,
422
- branchName,
423
- worktreePath,
424
- });
425
- await writeDurablePrompt({
426
- targetRoot: input.targetRoot,
427
- config: input.config,
428
- issueNumber: childIssueNumber,
429
- sessionId,
430
- promptText,
431
- });
432
- await store.upsertRun({
433
- issueNumber: childIssueNumber,
434
- parentIssueNumber: input.parentIssue.number,
435
- mode: 'tree-child',
436
- workspacePath: worktreePath,
437
- sessionId,
438
- branchName,
439
- promptPath,
440
- reportPath,
441
- logPath,
442
- retryCount: 0,
443
- createdAt: input.now.toISOString(),
444
- updatedAt: input.now.toISOString(),
445
- });
483
+ let sessionId = '';
484
+ let promptPath = '';
485
+ let reportPath = '';
486
+ let logPath = '';
487
+ let snapshotPath = '';
488
+ if (input.recovery) {
489
+ await input.git.ensureIssueWorktree({
490
+ targetRoot: input.targetRoot,
491
+ workspacePath: worktreePath,
492
+ branchName,
493
+ baseBranch: input.parentBranchName,
494
+ allowResume: true,
495
+ });
496
+ await input.issueAdapter.removeLabels(childIssueNumber, [input.config.github.labels.blocked.name]);
497
+ await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
498
+ }
499
+ else {
500
+ await input.git.createIssueWorktree({
501
+ targetRoot: input.targetRoot,
502
+ workspacePath: worktreePath,
503
+ branchName,
504
+ baseBranch: input.parentBranchName,
505
+ });
506
+ await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
507
+ await input.issueAdapter.postComment(childIssueNumber, `codex-orchestrator: claimed #${childIssueNumber} for tree-child autonomous work under #${input.parentIssue.number} at ${input.now.toISOString()}.`);
508
+ }
446
509
  let publishability;
447
- let rework;
510
+ let rework = input.recovery?.rework;
511
+ const reworkAttempts = [];
448
512
  const maxReworkAttempts = Math.max(input.config.loopPolicy.rework.maxAttempts, input.config.reviewGates.acceptanceProof.maxIterations - 1);
449
- for (let attempt = 0; attempt <= maxReworkAttempts; attempt++) {
513
+ const firstAttempt = input.recovery ? input.recovery.rework.attempt : 0;
514
+ for (let attempt = firstAttempt; attempt <= maxReworkAttempts; attempt++) {
450
515
  const attemptNow = new Date(input.now.getTime() + attempt);
516
+ sessionId = `tree-${input.parentIssue.number}-issue-${childIssueNumber}-${formatSessionTimestamp(attemptNow)}${attempt === 0 ? '' : `-attempt-${attempt}`}`;
517
+ promptPath = sessionPromptPath({
518
+ targetRoot: input.targetRoot,
519
+ config: input.config,
520
+ issueNumber: childIssueNumber,
521
+ sessionId,
522
+ });
523
+ reportPath = sessionReportPath({
524
+ targetRoot: input.targetRoot,
525
+ config: input.config,
526
+ issueNumber: childIssueNumber,
527
+ sessionId,
528
+ });
529
+ logPath = sessionLogPath({
530
+ targetRoot: input.targetRoot,
531
+ config: input.config,
532
+ issueNumber: childIssueNumber,
533
+ sessionId,
534
+ });
535
+ const isolatedHomePath = sessionCodexHomePath({ targetRoot: input.targetRoot, sessionId });
536
+ await mkdir(dirname(reportPath), { recursive: true });
537
+ await mkdir(isolatedHomePath, { recursive: true });
538
+ const promptText = buildIssueTreeChildPrompt({
539
+ parentIssue: input.parentIssue,
540
+ childIssue: input.child.issue,
541
+ config: input.config,
542
+ workflowPromptText: input.issueTreeWorkflowPrompt,
543
+ childMetadata: input.child.metadata,
544
+ dependencyIssues: input.dependencyIssues,
545
+ promptPath,
546
+ reportPath,
547
+ branchName,
548
+ worktreePath,
549
+ });
451
550
  const attemptPromptText = rework
452
- ? `${promptText}\n\n## Rework Request\nThis is an automatic rework attempt (#${rework.attempt}). Continue from the current worktree state; do not start over.\nThe previous attempt was blocked for these reasons:\n${rework.blockedReasons.map((reason) => `- ${reason}`).join('\n')}\nAddress the blockers, then produce a fresh completion report JSON for the runner.`
551
+ ? `${promptText}\n\n## Rework Request\nThis is an automatic rework attempt (#${rework.attempt}). Continue from the current worktree state; do not start over.\nThe previous attempt was blocked for these reasons:\n${rework.blockedReasons.map((reason) => `- ${reason}`).join('\n')}\n${rework.disableOptionalFigmaMcp ? 'Optional Figma MCP failed in the previous attempt. Continue without optional Figma MCP access unless the issue explicitly requires Figma.\n' : ''}Address the blockers, then produce a fresh completion report JSON for the runner.`
453
552
  : promptText;
454
553
  await writeDurablePrompt({
455
554
  targetRoot: input.targetRoot,
@@ -478,6 +577,23 @@ async function executeChild(input) {
478
577
  blockedBy: [],
479
578
  createdAt: attemptNow,
480
579
  });
580
+ snapshotPath = snapshot.path;
581
+ await store.upsertRun({
582
+ issueNumber: childIssueNumber,
583
+ parentIssueNumber: input.parentIssue.number,
584
+ mode: 'tree-child',
585
+ workspacePath: worktreePath,
586
+ sessionId,
587
+ branchName,
588
+ promptPath,
589
+ reportPath,
590
+ logPath,
591
+ retryCount: attempt,
592
+ createdAt: input.now.toISOString(),
593
+ updatedAt: attemptNow.toISOString(),
594
+ attemptStartedAt: attemptNow.toISOString(),
595
+ snapshotPath,
596
+ });
481
597
  await safeAppendEvent(input.events, {
482
598
  timestamp: attemptNow,
483
599
  issueNumber: childIssueNumber,
@@ -487,7 +603,7 @@ async function executeChild(input) {
487
603
  phase: 'tree-child',
488
604
  status: 'started',
489
605
  summary: rework ? `Starting tree-child rework attempt #${rework.attempt}.` : 'Starting tree-child Codex session.',
490
- artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshot.path),
606
+ artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
491
607
  });
492
608
  const beforeHead = await input.git.getHead(worktreePath);
493
609
  let codexResult;
@@ -505,6 +621,7 @@ async function executeChild(input) {
505
621
  branchName,
506
622
  phase: 'tree-child',
507
623
  logPath,
624
+ disableOptionalFigmaMcp: rework?.disableOptionalFigmaMcp,
508
625
  });
509
626
  }
510
627
  finally {
@@ -538,12 +655,49 @@ async function executeChild(input) {
538
655
  }),
539
656
  },
540
657
  });
541
- if (publishability.status === 'blocked'
542
- && attempt < maxReworkAttemptsForReasons(publishability.reasons, input.config)
543
- && shouldRequestImplementationRework(publishability.reasons, input.config)) {
544
- rework = { attempt: attempt + 1, blockedReasons: publishability.reasons };
545
- await mkdir(isolatedHomePath, { recursive: true });
546
- continue;
658
+ if (publishability.status === 'blocked') {
659
+ const reworkDecision = decideImplementationRework({
660
+ reasons: publishability.reasons,
661
+ config: input.config,
662
+ attempt,
663
+ });
664
+ reworkAttempts.push({
665
+ attempt,
666
+ maxAttempts: 'maxAttempts' in reworkDecision ? reworkDecision.maxAttempts : undefined,
667
+ decisionKind: reworkDecision.kind,
668
+ reasons: publishability.reasons,
669
+ promptPath,
670
+ reportPath,
671
+ logPath,
672
+ snapshotPath,
673
+ });
674
+ if (reworkDecision.kind === 'retry') {
675
+ rework = reworkDecision.rework;
676
+ await safeAppendEvent(input.events, {
677
+ timestamp: attemptNow,
678
+ issueNumber: childIssueNumber,
679
+ parentIssueNumber: input.parentIssue.number,
680
+ mode: 'tree-child',
681
+ sessionId,
682
+ phase: 'tree-child',
683
+ status: 'needs-rework',
684
+ summary: `Runner scheduled tree-child rework attempt #${reworkDecision.nextAttempt}.`,
685
+ artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
686
+ });
687
+ await mkdir(isolatedHomePath, { recursive: true });
688
+ continue;
689
+ }
690
+ await safeAppendEvent(input.events, {
691
+ timestamp: attemptNow,
692
+ issueNumber: childIssueNumber,
693
+ parentIssueNumber: input.parentIssue.number,
694
+ mode: 'tree-child',
695
+ sessionId,
696
+ phase: 'tree-child',
697
+ status: 'blocked',
698
+ summary: `Runner rework decision: ${reworkDecision.kind}.`,
699
+ artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
700
+ });
547
701
  }
548
702
  break;
549
703
  }
@@ -572,13 +726,14 @@ async function executeChild(input) {
572
726
  logPath,
573
727
  reportPath,
574
728
  acceptanceProof: evidence.acceptanceProof,
729
+ reworkAttempts,
575
730
  });
576
731
  const message = [
577
732
  'Child requested promotion instead of completing issue-tree work.',
578
733
  promotion ? `Reason: ${promotion.reason}` : undefined,
579
734
  promotion ? `Evidence: ${promotion.evidence.join(', ')}` : undefined,
580
735
  ].filter(Boolean).join(' ');
581
- throw new ChildExecutionBlockedError(message, durableRunSummary, undefined, evidence.acceptanceProof);
736
+ throw new ChildExecutionBlockedError(message, durableRunSummary, undefined, evidence.acceptanceProof, reworkAttempts);
582
737
  }
583
738
  if (publishability.status === 'blocked') {
584
739
  const evidence = buildBlockedHandoffEvidence({
@@ -600,8 +755,9 @@ async function executeChild(input) {
600
755
  logPath,
601
756
  reportPath,
602
757
  acceptanceProof: evidence.acceptanceProof,
758
+ reworkAttempts,
603
759
  });
604
- throw new ChildExecutionBlockedError(evidence.blockers.join('; '), durableRunSummary, undefined, evidence.acceptanceProof);
760
+ throw new ChildExecutionBlockedError(evidence.blockers.join('; '), durableRunSummary, undefined, evidence.acceptanceProof, reworkAttempts);
605
761
  }
606
762
  const freshContextReview = await runFreshContextReviewIfEnabled({
607
763
  targetRoot: input.targetRoot,
@@ -635,8 +791,9 @@ async function executeChild(input) {
635
791
  logPath,
636
792
  reportPath,
637
793
  acceptanceProof: evidence.acceptanceProof,
794
+ reworkAttempts,
638
795
  });
639
- throw new ChildExecutionBlockedError(evidence.blockers.join('; '), durableRunSummary, freshContextReview, evidence.acceptanceProof);
796
+ throw new ChildExecutionBlockedError(evidence.blockers.join('; '), durableRunSummary, freshContextReview, evidence.acceptanceProof, reworkAttempts);
640
797
  }
641
798
  const evidence = buildReviewReadyHandoffEvidence({
642
799
  publishability,
@@ -659,6 +816,7 @@ async function executeChild(input) {
659
816
  logPath,
660
817
  reportPath,
661
818
  acceptanceProof: evidence.acceptanceProof,
819
+ reworkAttempts,
662
820
  });
663
821
  return {
664
822
  child: input.child,
@@ -693,6 +851,7 @@ async function blockFailedBatch(issueAdapter, config, parentIssueNumber, failure
693
851
  details: ['Worktree preserved for maintainer inspection.'],
694
852
  freshContextReview: failure.freshContextReview,
695
853
  durableRunSummary: failure.durableRunSummary,
854
+ reworkAttempts: failure.reworkAttempts,
696
855
  acceptanceProof: failure.acceptanceProof,
697
856
  }));
698
857
  }
@@ -747,6 +906,10 @@ async function handleMergeConflict(git, issueAdapter, config, parentIssueNumber,
747
906
  }
748
907
  async function markCompletedChildrenReviewReady(issueAdapter, config, store, parentIssueNumber, childResults) {
749
908
  for (const childResult of childResults) {
909
+ if (childResult.recovered) {
910
+ await store.removeRun(childResult.child.issue.number);
911
+ continue;
912
+ }
750
913
  await issueAdapter.removeLabels(childResult.child.issue.number, [config.github.labels.running.name]);
751
914
  await issueAdapter.addLabels(childResult.child.issue.number, [config.github.labels.review.name]);
752
915
  await issueAdapter.postComment(childResult.child.issue.number, buildChildReviewReport({ parentIssueNumber, result: childResult }));
@@ -755,6 +918,9 @@ async function markCompletedChildrenReviewReady(issueAdapter, config, store, par
755
918
  }
756
919
  async function blockCompletedChildren(issueAdapter, config, store, parentIssueNumber, childResults, reason, parentWorktreePath) {
757
920
  for (const result of childResults) {
921
+ if (result.recovered) {
922
+ continue;
923
+ }
758
924
  await issueAdapter.removeLabels(result.child.issue.number, [config.github.labels.running.name]);
759
925
  await issueAdapter.addLabels(result.child.issue.number, [config.github.labels.blocked.name]);
760
926
  await issueAdapter.postComment(result.child.issue.number, buildChildBlockedReport({
@@ -771,16 +937,20 @@ async function blockCompletedChildren(issueAdapter, config, store, parentIssueNu
771
937
  await store.removeRun(result.child.issue.number);
772
938
  }
773
939
  }
774
- async function finishPlanBlocked(issueAdapter, config, events, result, reasons, mutatedChildren) {
775
- const reportComment = buildParentBlockedReport({
940
+ async function finishPlanBlocked(issueAdapter, config, events, result, reasons, mutatedChildren, commentMarker) {
941
+ const reportBody = buildParentBlockedReport({
776
942
  parentIssueNumber: result.parentIssueNumber,
777
943
  reasons,
778
944
  logPath: result.logPath,
779
945
  mutatedChildren,
780
946
  });
947
+ const reportComment = commentMarker ? `<!-- codex-orchestrator:${commentMarker} -->\n${reportBody}` : reportBody;
781
948
  await issueAdapter.removeLabels(result.parentIssueNumber, [config.github.labels.running.name]);
782
949
  await issueAdapter.addLabels(result.parentIssueNumber, [config.github.labels.blocked.name]);
783
- await issueAdapter.postComment(result.parentIssueNumber, reportComment);
950
+ const currentIssue = commentMarker ? await issueAdapter.getIssue(result.parentIssueNumber) : undefined;
951
+ if (!commentMarker || !currentIssue?.comments.some((comment) => comment.body.includes(commentMarker))) {
952
+ await issueAdapter.postComment(result.parentIssueNumber, reportComment);
953
+ }
784
954
  await safeAppendEvent(events, {
785
955
  issueNumber: result.parentIssueNumber,
786
956
  mode: 'plan-parent',