codex-orchestrator 0.1.40 → 0.1.42

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 (101) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/src/cli.js +9 -0
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/config/schema.d.ts +6 -0
  5. package/dist/src/config/schema.d.ts.map +1 -1
  6. package/dist/src/config/schema.js +31 -0
  7. package/dist/src/config/schema.js.map +1 -1
  8. package/dist/src/git/worktree.d.ts +3 -1
  9. package/dist/src/git/worktree.d.ts.map +1 -1
  10. package/dist/src/git/worktree.js +51 -17
  11. package/dist/src/git/worktree.js.map +1 -1
  12. package/dist/src/github/gh-issue-adapter.d.ts +2 -0
  13. package/dist/src/github/gh-issue-adapter.d.ts.map +1 -1
  14. package/dist/src/github/gh-issue-adapter.js +7 -1
  15. package/dist/src/github/gh-issue-adapter.js.map +1 -1
  16. package/dist/src/github/issues.d.ts +3 -0
  17. package/dist/src/github/issues.d.ts.map +1 -1
  18. package/dist/src/github/issues.js +7 -1
  19. package/dist/src/github/issues.js.map +1 -1
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +1 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/runner/acceptance-proof-loop.d.ts +115 -0
  25. package/dist/src/runner/acceptance-proof-loop.d.ts.map +1 -0
  26. package/dist/src/runner/acceptance-proof-loop.js +401 -0
  27. package/dist/src/runner/acceptance-proof-loop.js.map +1 -0
  28. package/dist/src/runner/acceptance-proof-runner.d.ts +2 -20
  29. package/dist/src/runner/acceptance-proof-runner.d.ts.map +1 -1
  30. package/dist/src/runner/acceptance-proof-runner.js +9 -103
  31. package/dist/src/runner/acceptance-proof-runner.js.map +1 -1
  32. package/dist/src/runner/agent-attempt.d.ts +105 -0
  33. package/dist/src/runner/agent-attempt.d.ts.map +1 -0
  34. package/dist/src/runner/agent-attempt.js +235 -0
  35. package/dist/src/runner/agent-attempt.js.map +1 -0
  36. package/dist/src/runner/auto-visual-proof-command.d.ts.map +1 -1
  37. package/dist/src/runner/auto-visual-proof-command.js +6 -12
  38. package/dist/src/runner/auto-visual-proof-command.js.map +1 -1
  39. package/dist/src/runner/command-utils.d.ts +6 -2
  40. package/dist/src/runner/command-utils.d.ts.map +1 -1
  41. package/dist/src/runner/command-utils.js +34 -1
  42. package/dist/src/runner/command-utils.js.map +1 -1
  43. package/dist/src/runner/daemon-command.d.ts +3 -1
  44. package/dist/src/runner/daemon-command.d.ts.map +1 -1
  45. package/dist/src/runner/daemon-command.js +37 -0
  46. package/dist/src/runner/daemon-command.js.map +1 -1
  47. package/dist/src/runner/durable-run-summary.d.ts +19 -0
  48. package/dist/src/runner/durable-run-summary.d.ts.map +1 -1
  49. package/dist/src/runner/durable-run-summary.js +107 -2
  50. package/dist/src/runner/durable-run-summary.js.map +1 -1
  51. package/dist/src/runner/handoff-evidence.d.ts +1 -0
  52. package/dist/src/runner/handoff-evidence.d.ts.map +1 -1
  53. package/dist/src/runner/handoff-evidence.js +2 -2
  54. package/dist/src/runner/handoff-evidence.js.map +1 -1
  55. package/dist/src/runner/hygiene-cleanup.d.ts +15 -0
  56. package/dist/src/runner/hygiene-cleanup.d.ts.map +1 -0
  57. package/dist/src/runner/hygiene-cleanup.js +25 -0
  58. package/dist/src/runner/hygiene-cleanup.js.map +1 -0
  59. package/dist/src/runner/issue-tree.d.ts +1 -1
  60. package/dist/src/runner/issue-tree.d.ts.map +1 -1
  61. package/dist/src/runner/issue-tree.js +15 -6
  62. package/dist/src/runner/issue-tree.js.map +1 -1
  63. package/dist/src/runner/local-execution-session.d.ts.map +1 -1
  64. package/dist/src/runner/local-execution-session.js +84 -127
  65. package/dist/src/runner/local-execution-session.js.map +1 -1
  66. package/dist/src/runner/plan-auto-command.d.ts +3 -0
  67. package/dist/src/runner/plan-auto-command.d.ts.map +1 -1
  68. package/dist/src/runner/plan-auto-command.js +319 -310
  69. package/dist/src/runner/plan-auto-command.js.map +1 -1
  70. package/dist/src/runner/plan-auto-recovery.d.ts +83 -0
  71. package/dist/src/runner/plan-auto-recovery.d.ts.map +1 -0
  72. package/dist/src/runner/plan-auto-recovery.js +303 -0
  73. package/dist/src/runner/plan-auto-recovery.js.map +1 -0
  74. package/dist/src/runner/review-gate-policy.d.ts +7 -1
  75. package/dist/src/runner/review-gate-policy.d.ts.map +1 -1
  76. package/dist/src/runner/review-gate-policy.js +11 -123
  77. package/dist/src/runner/review-gate-policy.js.map +1 -1
  78. package/dist/src/runner/rework-policy.js +1 -1
  79. package/dist/src/runner/rework-policy.js.map +1 -1
  80. package/dist/src/runner/runner-handoff-decision.d.ts.map +1 -1
  81. package/dist/src/runner/runner-handoff-decision.js +22 -4
  82. package/dist/src/runner/runner-handoff-decision.js.map +1 -1
  83. package/dist/src/runner/scoped-auto-command.d.ts.map +1 -1
  84. package/dist/src/runner/scoped-auto-command.js +104 -221
  85. package/dist/src/runner/scoped-auto-command.js.map +1 -1
  86. package/dist/src/runner/scoped-recovery.d.ts +13 -0
  87. package/dist/src/runner/scoped-recovery.d.ts.map +1 -1
  88. package/dist/src/runner/scoped-recovery.js +7 -7
  89. package/dist/src/runner/scoped-recovery.js.map +1 -1
  90. package/dist/src/runner/terminal-outcome.d.ts +58 -0
  91. package/dist/src/runner/terminal-outcome.d.ts.map +1 -0
  92. package/dist/src/runner/terminal-outcome.js +73 -0
  93. package/dist/src/runner/terminal-outcome.js.map +1 -0
  94. package/dist/src/runner/visual-proof-runner.d.ts +2 -0
  95. package/dist/src/runner/visual-proof-runner.d.ts.map +1 -1
  96. package/dist/src/runner/visual-proof-runner.js +30 -8
  97. package/dist/src/runner/visual-proof-runner.js.map +1 -1
  98. package/dist/src/setup/setup-command.js +1 -0
  99. package/dist/src/setup/setup-command.js.map +1 -1
  100. package/docs/deep-dive.md +26 -0
  101. package/package.json +1 -1
@@ -1,11 +1,11 @@
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';
5
6
  import { GitMergeConflictError, GitWorktreeManager, renderBranchTemplate } from '../git/worktree.js';
6
7
  import { GhCliIssueAdapter } from '../github/gh-issue-adapter.js';
7
8
  import { GhCliPullRequestAdapter } from '../github/gh-pull-request-adapter.js';
8
- import { verifyPullRequestRefs } from '../github/pull-requests.js';
9
9
  import { defaultShellCommandExecutor } from '../process/command.js';
10
10
  import { formatSessionTimestamp, readRunnerConfig, runConfiguredChecks, } from './command-utils.js';
11
11
  import { buildChildBlockedReport, buildChildReviewReport, buildIssueTreePullRequestBody, buildIssueTreeReviewReport, buildParentBlockedReport, } from './handoff-evidence.js';
@@ -15,15 +15,16 @@ import { writeContextSnapshot } from './context-snapshot.js';
15
15
  import { readPlanAutoCompletionReport, } from './completion-report.js';
16
16
  import { claimIssue, discoverIssueWork } from './issue-state-machine.js';
17
17
  import { collectExecutableChildBatches, persistAutonomousChildNode, readAutonomousChildNodes, topologicalPlanNodes, } from './issue-tree.js';
18
- import { runImplementationPublishabilityCheck } from './local-execution-session.js';
19
18
  import { RunnerLifecycleEventStore } from './lifecycle-events.js';
20
19
  import { RunnerStateStore } from './local-state.js';
20
+ import { classifyPlanAutoBlockedChildRecovery, classifyPlanAutoCompletedChildRecovery, classifyPlanAutoParentRecovery, } from './plan-auto-recovery.js';
21
21
  import { buildIssueTreeChildPrompt, buildPlanAutoPrompt, sessionPromptPath, sessionReportPath, writeDurablePrompt, } from './prompt.js';
22
- import { decideImplementationRework } from './rework-policy.js';
22
+ import { runAgentAttemptLoop } from './agent-attempt.js';
23
23
  import { evaluateParentRiskRoutingGate } from './review-gates.js';
24
24
  import { buildBlockedHandoffEvidence, buildPromotionAsBlockedHandoffEvidence, buildReviewReadyHandoffEvidence, } from './runner-handoff-decision.js';
25
25
  import { sessionLogPath } from './run-log.js';
26
26
  import { cleanupSessionCodexHome, sessionCodexHomePath } from './session-home.js';
27
+ import { finishBlockedTerminalOutcome, finishReviewReadyCommentTerminalOutcome, finishReviewReadyTerminalOutcome, } from './terminal-outcome.js';
27
28
  class ChildExecutionBlockedError extends Error {
28
29
  durableRunSummary;
29
30
  freshContextReview;
@@ -51,16 +52,36 @@ export async function runPlanAutoCommand(options) {
51
52
  if (!parentIssue) {
52
53
  throw new Error(`Issue #${options.issueNumber} was not found`);
53
54
  }
55
+ const branchName = renderBranchTemplate(config.branches.issueTree, { parentIssueNumber: options.issueNumber });
56
+ const worktreePath = join(targetRoot, config.runner.workspaceRoot, `tree-${options.issueNumber}`);
57
+ const store = new RunnerStateStore(targetRoot, config);
58
+ const parentRecovery = await classifyPlanAutoParentRecovery({
59
+ targetRoot,
60
+ config,
61
+ parentIssue,
62
+ branchName,
63
+ worktreePath,
64
+ baseSha: resolvedBase.sha,
65
+ state: await store.load(),
66
+ git,
67
+ now,
68
+ hostname: options.hostname,
69
+ processProbe: options.processProbe,
70
+ });
54
71
  const decision = discoverIssueWork([parentIssue], config)[0];
55
- if (!decision || decision.kind !== 'eligible' || decision.mode !== 'plan-parent') {
72
+ const parentLabels = new Set(parentIssue.labels.map((label) => label.name));
73
+ const alreadyRunningPlanParent = decision?.kind === 'skipped'
74
+ && decision.reasonCode === 'already-running'
75
+ && parentIssue.state === 'OPEN'
76
+ && parentLabels.has(config.github.labels.planAuto.name)
77
+ && !parentLabels.has(config.github.labels.child.name);
78
+ if (!decision || !((decision.kind === 'eligible' && decision.mode === 'plan-parent') || alreadyRunningPlanParent)) {
56
79
  const reason = decision?.kind === 'skipped' ? decision.reason : 'not agent:plan-auto';
57
80
  throw new Error(`Issue #${options.issueNumber} is not eligible for agent:plan-auto planning: ${reason}`);
58
81
  }
59
82
  const workflowPrompts = await readPlanWorkflowPrompts(targetRoot, config);
60
83
  const issueTreeWorkflowPrompt = await readIssueTreeWorkflowPrompt(targetRoot, config);
61
84
  const acceptanceProofWorkflowPrompt = await readAcceptanceProofWorkflowPrompt(targetRoot, config);
62
- const branchName = renderBranchTemplate(config.branches.issueTree, { parentIssueNumber: options.issueNumber });
63
- const worktreePath = join(targetRoot, config.runner.workspaceRoot, `tree-${options.issueNumber}`);
64
85
  let promptPath = '';
65
86
  let reportPath = '';
66
87
  let logPath = '';
@@ -68,19 +89,38 @@ export async function runPlanAutoCommand(options) {
68
89
  let parentSnapshotPath = '';
69
90
  const childIssues = [];
70
91
  let pullRequest;
71
- let store;
72
92
  const executionResults = [];
73
93
  const events = new RunnerLifecycleEventStore(targetRoot, config);
74
94
  const buildBase = () => baseResult(options.issueNumber, branchName, worktreePath, promptPath, reportPath, logPath, childIssues, sessionId, parentSnapshotPath);
75
- await claimIssue(issueAdapter, config, options.issueNumber, 'plan-parent', now);
95
+ if (parentRecovery.kind === 'hard-block') {
96
+ return finishPlanBlocked(issueAdapter, config, events, buildBase(), [parentRecovery.reason], childIssues, parentRecovery.marker);
97
+ }
98
+ if (alreadyRunningPlanParent && parentRecovery.kind !== 'resume-parent') {
99
+ 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`);
100
+ }
101
+ if (!alreadyRunningPlanParent) {
102
+ await claimIssue(issueAdapter, config, options.issueNumber, 'plan-parent', now);
103
+ }
76
104
  try {
77
- await git.createIssueWorktree({
78
- targetRoot,
79
- workspacePath: worktreePath,
80
- branchName,
81
- baseBranch: resolvedBase.sha,
82
- requiredBaseSha: resolvedBase.sha,
83
- });
105
+ if (parentRecovery.kind === 'resume-parent') {
106
+ await git.ensureIssueWorktree({
107
+ targetRoot,
108
+ workspacePath: worktreePath,
109
+ branchName,
110
+ baseBranch: resolvedBase.sha,
111
+ requiredBaseSha: resolvedBase.sha,
112
+ allowResume: true,
113
+ });
114
+ }
115
+ else {
116
+ await git.createIssueWorktree({
117
+ targetRoot,
118
+ workspacePath: worktreePath,
119
+ branchName,
120
+ baseBranch: resolvedBase.sha,
121
+ requiredBaseSha: resolvedBase.sha,
122
+ });
123
+ }
84
124
  sessionId = `plan-${options.issueNumber}-${formatSessionTimestamp(now)}`;
85
125
  promptPath = sessionPromptPath({ targetRoot, config, issueNumber: options.issueNumber, sessionId });
86
126
  reportPath = sessionReportPath({ targetRoot, config, issueNumber: options.issueNumber, sessionId });
@@ -132,7 +172,6 @@ export async function runPlanAutoCommand(options) {
132
172
  summary: 'Starting parent planning Codex session.',
133
173
  artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshot.path),
134
174
  });
135
- store = new RunnerStateStore(targetRoot, config);
136
175
  await store.upsertRun({
137
176
  issueNumber: options.issueNumber,
138
177
  mode: 'plan-parent',
@@ -145,6 +184,10 @@ export async function runPlanAutoCommand(options) {
145
184
  retryCount: 0,
146
185
  createdAt: now.toISOString(),
147
186
  updatedAt: now.toISOString(),
187
+ ownerPid: process.pid,
188
+ host: (options.hostname ?? osHostname)(),
189
+ leaseUpdatedAt: now.toISOString(),
190
+ baseSha: resolvedBase.sha,
148
191
  });
149
192
  const beforeHead = await git.getHead(worktreePath);
150
193
  let codexResult;
@@ -198,7 +241,60 @@ export async function runPlanAutoCommand(options) {
198
241
  childIssues.push(persisted);
199
242
  }
200
243
  const childNodes = await readAutonomousChildNodes(issueAdapter, config, options.issueNumber, childIssues);
201
- const batches = collectExecutableChildBatches(childNodes, config);
244
+ const childRecoveryState = await store.load();
245
+ const childRecoveryDecisions = await Promise.all(childNodes.map(async (child) => {
246
+ const completed = await classifyPlanAutoCompletedChildRecovery({
247
+ targetRoot,
248
+ config,
249
+ parentIssueNumber: options.issueNumber,
250
+ parentBranchName: branchName,
251
+ child,
252
+ state: childRecoveryState,
253
+ git,
254
+ });
255
+ return completed.kind === 'execute-child'
256
+ ? classifyPlanAutoBlockedChildRecovery({
257
+ targetRoot,
258
+ config,
259
+ parentIssueNumber: options.issueNumber,
260
+ child,
261
+ state: childRecoveryState,
262
+ git,
263
+ })
264
+ : completed;
265
+ }));
266
+ const recoveredStableIds = [];
267
+ const resumableBlockedStableIds = [];
268
+ const childRecoveryByStableId = new Map();
269
+ for (const decision of childRecoveryDecisions) {
270
+ if (decision.kind === 'hard-block') {
271
+ return finishPlanBlocked(issueAdapter, config, events, buildBase(), [decision.reason], childIssues, decision.marker);
272
+ }
273
+ if (decision.kind === 'recovered-completed-child') {
274
+ recoveredStableIds.push(decision.child.metadata.stableId);
275
+ executionResults.push({
276
+ child: decision.child,
277
+ branchName: decision.branchName,
278
+ worktreePath: decision.worktreePath,
279
+ promptPath: decision.promptPath,
280
+ reportPath: decision.reportPath,
281
+ logPath: decision.logPath,
282
+ changedFiles: decision.changedFiles,
283
+ validation: decision.validation,
284
+ artifacts: [],
285
+ commits: [],
286
+ skippedChecks: decision.skippedChecks,
287
+ residualRisks: decision.residualRisks,
288
+ durableRunSummary: decision.durableRunSummary,
289
+ recovered: true,
290
+ });
291
+ }
292
+ else if (decision.kind === 'resume-child-rework') {
293
+ resumableBlockedStableIds.push(decision.child.metadata.stableId);
294
+ childRecoveryByStableId.set(decision.child.metadata.stableId, decision);
295
+ }
296
+ }
297
+ const batches = collectExecutableChildBatches(childNodes, config, recoveredStableIds, resumableBlockedStableIds);
202
298
  if (!batches.ok) {
203
299
  return finishPlanBlocked(issueAdapter, config, events, base, batches.errors, childIssues);
204
300
  }
@@ -218,6 +314,7 @@ export async function runPlanAutoCommand(options) {
218
314
  acceptanceProofWorkflowPrompt,
219
315
  now,
220
316
  events,
317
+ recovery: childRecoveryByStableId.get(child.metadata.stableId),
221
318
  })));
222
319
  const failures = [];
223
320
  for (const [index, result] of settled.entries()) {
@@ -268,7 +365,7 @@ export async function runPlanAutoCommand(options) {
268
365
  executionResults.push(childResult);
269
366
  }
270
367
  }
271
- const finalValidation = await runConfiguredChecks(config, worktreePath, shellExecutor, []);
368
+ const finalValidation = await runConfiguredChecks(config, worktreePath, shellExecutor, [], { phase: 'parent-integration' });
272
369
  const failedFinalValidation = finalValidation.filter((line) => line.status === 'failed');
273
370
  if (failedFinalValidation.length > 0) {
274
371
  await blockCompletedChildren(issueAdapter, config, store, options.issueNumber, executionResults, 'Parent integration configured checks failed before publication.', worktreePath);
@@ -277,37 +374,44 @@ export async function runPlanAutoCommand(options) {
277
374
  ...failedFinalValidation.map((line) => `${line.command}: ${line.status} - ${line.summary}`),
278
375
  ], childIssues);
279
376
  }
280
- await git.pushBranch({ worktreePath, branchName });
281
- pullRequest = await pullRequestAdapter.createDraftPullRequest({
282
- title: renderParentTemplate(config.pullRequests.issueTreeTitle, options.issueNumber),
283
- body: buildIssueTreePullRequestBody({
377
+ const handoff = await finishReviewReadyTerminalOutcome({
378
+ issueNumber: options.issueNumber,
379
+ config,
380
+ branchName,
381
+ baseBranch: resolvedBase.prBaseBranch,
382
+ worktreePath,
383
+ git,
384
+ pullRequestAdapter,
385
+ issueAdapter,
386
+ pullRequest: {
387
+ title: renderParentTemplate(config.pullRequests.issueTreeTitle, options.issueNumber),
388
+ body: buildIssueTreePullRequestBody({
389
+ parentIssueNumber: options.issueNumber,
390
+ childIssues,
391
+ childResults: executionResults,
392
+ finalValidation,
393
+ sizeRisk: report.sizeRisk,
394
+ parentReviewHandoff: report.parentReviewHandoff,
395
+ riskRoutingWarnings,
396
+ }),
397
+ },
398
+ findExistingPullRequest: false,
399
+ reportComment: (verifiedPullRequest) => buildIssueTreeReviewReport({
284
400
  parentIssueNumber: options.issueNumber,
285
- childIssues,
401
+ pullRequest: verifiedPullRequest,
402
+ batches: batches.batches,
286
403
  childResults: executionResults,
287
404
  finalValidation,
288
405
  sizeRisk: report.sizeRisk,
289
406
  parentReviewHandoff: report.parentReviewHandoff,
290
407
  riskRoutingWarnings,
291
408
  }),
292
- headBranch: branchName,
293
- baseBranch: resolvedBase.prBaseBranch,
294
- });
295
- pullRequest = await verifyPullRequestRefs(pullRequestAdapter, pullRequest, branchName, resolvedBase.prBaseBranch);
296
- const reportComment = buildIssueTreeReviewReport({
297
- parentIssueNumber: options.issueNumber,
298
- pullRequest,
299
- batches: batches.batches,
300
- childResults: executionResults,
301
- finalValidation,
302
- sizeRisk: report.sizeRisk,
303
- parentReviewHandoff: report.parentReviewHandoff,
304
- riskRoutingWarnings,
409
+ onPullRequestReady: (verifiedPullRequest) => {
410
+ pullRequest = verifiedPullRequest;
411
+ },
412
+ beforeIssueMutation: () => markCompletedChildrenReviewReady(issueAdapter, config, store, options.issueNumber, executionResults),
413
+ afterComment: () => store.removeRun(options.issueNumber),
305
414
  });
306
- await markCompletedChildrenReviewReady(issueAdapter, config, store, options.issueNumber, executionResults);
307
- await issueAdapter.removeLabels(options.issueNumber, [config.github.labels.running.name]);
308
- await issueAdapter.addLabels(options.issueNumber, [config.github.labels.review.name]);
309
- await issueAdapter.postComment(options.issueNumber, reportComment);
310
- await store.removeRun(options.issueNumber);
311
415
  await safeAppendEvent(events, {
312
416
  issueNumber: options.issueNumber,
313
417
  mode: 'plan-parent',
@@ -317,14 +421,14 @@ export async function runPlanAutoCommand(options) {
317
421
  summary: 'Issue-tree execution completed draft PR handoff.',
318
422
  artifacts: [
319
423
  ...sessionArtifacts(promptPath, reportPath, logPath, parentSnapshotPath),
320
- { kind: 'pr', url: pullRequest.url, description: 'Draft pull request' },
424
+ { kind: 'pr', url: handoff.pullRequest.url, description: 'Draft pull request' },
321
425
  ],
322
426
  });
323
427
  return {
324
428
  ...buildBase(),
325
429
  status: 'review-ready',
326
- pullRequest,
327
- reportComment,
430
+ pullRequest: handoff.pullRequest,
431
+ reportComment: handoff.reportComment,
328
432
  riskRoutingWarnings,
329
433
  };
330
434
  }
@@ -333,7 +437,7 @@ export async function runPlanAutoCommand(options) {
333
437
  if (pullRequest) {
334
438
  throw new Error(`Issue-tree execution failed after draft PR creation (${pullRequest.url}); not marking parent blocked: ${message}`);
335
439
  }
336
- if (store && executionResults.length > 0) {
440
+ if (executionResults.length > 0) {
337
441
  await blockCompletedChildren(issueAdapter, config, store, options.issueNumber, executionResults, `Parent publication failed before review handoff: ${message}`, worktreePath);
338
442
  }
339
443
  return finishPlanBlocked(issueAdapter, config, events, buildBase(), [message], childIssues);
@@ -381,217 +485,95 @@ async function executeChild(input) {
381
485
  const childIssueNumber = input.child.issue.number;
382
486
  const branchName = `codex/tree-${input.parentIssue.number}-issue-${childIssueNumber}`;
383
487
  const worktreePath = join(input.targetRoot, input.config.runner.workspaceRoot, `tree-${input.parentIssue.number}-issue-${childIssueNumber}`);
384
- const store = new RunnerStateStore(input.targetRoot, input.config);
385
488
  let sessionId = '';
386
489
  let promptPath = '';
387
490
  let reportPath = '';
388
491
  let logPath = '';
389
492
  let snapshotPath = '';
390
- await input.git.createIssueWorktree({
391
- targetRoot: input.targetRoot,
392
- workspacePath: worktreePath,
393
- branchName,
394
- baseBranch: input.parentBranchName,
395
- });
396
- await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
397
- await input.issueAdapter.postComment(childIssueNumber, `codex-orchestrator: claimed #${childIssueNumber} for tree-child autonomous work under #${input.parentIssue.number} at ${input.now.toISOString()}.`);
398
- let publishability;
399
- let rework;
400
- const reworkAttempts = [];
401
- const maxReworkAttempts = Math.max(input.config.loopPolicy.rework.maxAttempts, input.config.reviewGates.acceptanceProof.maxIterations - 1);
402
- for (let attempt = 0; attempt <= maxReworkAttempts; attempt++) {
403
- const attemptNow = new Date(input.now.getTime() + attempt);
404
- sessionId = `tree-${input.parentIssue.number}-issue-${childIssueNumber}-${formatSessionTimestamp(attemptNow)}${attempt === 0 ? '' : `-attempt-${attempt}`}`;
405
- promptPath = sessionPromptPath({
406
- targetRoot: input.targetRoot,
407
- config: input.config,
408
- issueNumber: childIssueNumber,
409
- sessionId,
410
- });
411
- reportPath = sessionReportPath({
412
- targetRoot: input.targetRoot,
413
- config: input.config,
414
- issueNumber: childIssueNumber,
415
- sessionId,
416
- });
417
- logPath = sessionLogPath({
418
- targetRoot: input.targetRoot,
419
- config: input.config,
420
- issueNumber: childIssueNumber,
421
- sessionId,
422
- });
423
- const isolatedHomePath = sessionCodexHomePath({ targetRoot: input.targetRoot, sessionId });
424
- await mkdir(dirname(reportPath), { recursive: true });
425
- await mkdir(isolatedHomePath, { recursive: true });
426
- const promptText = buildIssueTreeChildPrompt({
427
- parentIssue: input.parentIssue,
428
- childIssue: input.child.issue,
429
- config: input.config,
430
- workflowPromptText: input.issueTreeWorkflowPrompt,
431
- childMetadata: input.child.metadata,
432
- dependencyIssues: input.dependencyIssues,
433
- promptPath,
434
- reportPath,
435
- branchName,
436
- worktreePath,
437
- });
438
- const attemptPromptText = rework
439
- ? `${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.`
440
- : promptText;
441
- await writeDurablePrompt({
442
- targetRoot: input.targetRoot,
443
- config: input.config,
444
- issueNumber: childIssueNumber,
445
- sessionId,
446
- promptText: attemptPromptText,
447
- });
448
- const snapshot = await writeContextSnapshot({
493
+ if (input.recovery) {
494
+ await input.git.ensureIssueWorktree({
449
495
  targetRoot: input.targetRoot,
450
- config: input.config,
451
- issue: input.child.issue,
452
- mode: 'tree-child',
453
- phase: 'tree-child',
454
- decision: rework
455
- ? `tree-child rework attempt #${rework.attempt} under parent #${input.parentIssue.number}`
456
- : `child issue execution under parent #${input.parentIssue.number}`,
457
- sessionId,
458
- worktreePath,
459
- promptPath,
460
- reportPath,
461
- logPath,
496
+ workspacePath: worktreePath,
462
497
  branchName,
463
498
  baseBranch: input.parentBranchName,
464
- parentIssueNumber: input.parentIssue.number,
465
- blockedBy: [],
466
- createdAt: attemptNow,
499
+ allowResume: true,
467
500
  });
468
- snapshotPath = snapshot.path;
469
- await store.upsertRun({
470
- issueNumber: childIssueNumber,
471
- parentIssueNumber: input.parentIssue.number,
472
- mode: 'tree-child',
501
+ await input.issueAdapter.removeLabels(childIssueNumber, [input.config.github.labels.blocked.name]);
502
+ await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
503
+ }
504
+ else {
505
+ await input.git.createIssueWorktree({
506
+ targetRoot: input.targetRoot,
473
507
  workspacePath: worktreePath,
474
- sessionId,
475
508
  branchName,
476
- promptPath,
477
- reportPath,
478
- logPath,
479
- retryCount: attempt,
480
- createdAt: input.now.toISOString(),
481
- updatedAt: attemptNow.toISOString(),
482
- attemptStartedAt: attemptNow.toISOString(),
483
- snapshotPath,
484
- });
485
- await safeAppendEvent(input.events, {
486
- timestamp: attemptNow,
487
- issueNumber: childIssueNumber,
488
- parentIssueNumber: input.parentIssue.number,
489
- mode: 'tree-child',
490
- sessionId,
491
- phase: 'tree-child',
492
- status: 'started',
493
- summary: rework ? `Starting tree-child rework attempt #${rework.attempt}.` : 'Starting tree-child Codex session.',
494
- artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
509
+ baseBranch: input.parentBranchName,
495
510
  });
496
- const beforeHead = await input.git.getHead(worktreePath);
497
- let codexResult;
498
- try {
499
- codexResult = await input.codexAdapter.run({
500
- targetRoot: input.targetRoot,
511
+ await input.issueAdapter.addLabels(childIssueNumber, [input.config.github.labels.running.name]);
512
+ await input.issueAdapter.postComment(childIssueNumber, `codex-orchestrator: claimed #${childIssueNumber} for tree-child autonomous work under #${input.parentIssue.number} at ${input.now.toISOString()}.`);
513
+ }
514
+ const attemptLoop = await runAgentAttemptLoop({
515
+ targetRoot: input.targetRoot,
516
+ config: input.config,
517
+ issue: input.child.issue,
518
+ issueNumber: childIssueNumber,
519
+ parentIssueNumber: input.parentIssue.number,
520
+ mode: 'tree-child',
521
+ phase: 'tree-child',
522
+ branchName,
523
+ worktreePath,
524
+ baseBranch: input.parentBranchName,
525
+ createdAt: input.now,
526
+ firstAttempt: input.recovery ? input.recovery.rework.attempt : 0,
527
+ initialRework: input.recovery?.rework,
528
+ buildSessionId: ({ attempt, attemptNow }) => `tree-${input.parentIssue.number}-issue-${childIssueNumber}-${formatSessionTimestamp(attemptNow)}${attempt === 0 ? '' : `-attempt-${attempt}`}`,
529
+ buildPrompt: ({ promptPath: attemptPromptPath, reportPath: attemptReportPath, rework }) => {
530
+ const promptText = buildIssueTreeChildPrompt({
531
+ parentIssue: input.parentIssue,
532
+ childIssue: input.child.issue,
501
533
  config: input.config,
502
- worktreePath,
503
- promptPath,
504
- promptText: attemptPromptText,
505
- reportPath,
506
- isolatedHomePath,
507
- issueNumber: childIssueNumber,
508
- sessionId,
534
+ workflowPromptText: input.issueTreeWorkflowPrompt,
535
+ childMetadata: input.child.metadata,
536
+ dependencyIssues: input.dependencyIssues,
537
+ promptPath: attemptPromptPath,
538
+ reportPath: attemptReportPath,
509
539
  branchName,
510
- phase: 'tree-child',
511
- logPath,
512
- disableOptionalFigmaMcp: rework?.disableOptionalFigmaMcp,
540
+ worktreePath,
513
541
  });
514
- }
515
- finally {
516
- await cleanupSessionCodexHome(isolatedHomePath);
517
- }
518
- const afterHead = await input.git.getHead(worktreePath);
519
- publishability = await runImplementationPublishabilityCheck({
520
- config: input.config,
521
- issue: input.child.issue,
542
+ return rework
543
+ ? `${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.`
544
+ : promptText;
545
+ },
546
+ buildSnapshotDecision: ({ rework }) => rework
547
+ ? `tree-child rework attempt #${rework.attempt} under parent #${input.parentIssue.number}`
548
+ : `child issue execution under parent #${input.parentIssue.number}`,
549
+ startedSummary: ({ rework }) => rework ? `Starting tree-child rework attempt #${rework.attempt}.` : 'Starting tree-child Codex session.',
550
+ reworkScheduledSummary: ({ nextAttempt }) => `Runner scheduled tree-child rework attempt #${nextAttempt}.`,
551
+ missingPublishabilityMessage: 'Runner internal error: missing child publishability result',
552
+ codexAdapter: input.codexAdapter,
553
+ git: input.git,
554
+ shellExecutor: input.shellExecutor,
555
+ commitMessage: `Codex: implement issue #${childIssueNumber} for parent #${input.parentIssue.number}`,
556
+ events: input.events,
557
+ acceptanceProof: {
522
558
  targetRoot: input.targetRoot,
523
- worktreePath,
524
- reportPath,
525
- beforeHead,
526
- afterHead,
527
- codexResult,
528
- git: input.git,
529
- shellExecutor: input.shellExecutor,
530
- commitMessage: `Codex: implement issue #${childIssueNumber} for parent #${input.parentIssue.number}`,
531
- acceptanceProof: {
532
- targetRoot: input.targetRoot,
533
- sessionId,
534
- branchName,
535
- workflowPromptText: input.acceptanceProofWorkflowPrompt,
536
- codexAdapter: input.codexAdapter,
537
- onAttemptEvent: (event) => appendAcceptanceProofEvent({
538
- events: input.events,
539
- issueNumber: childIssueNumber,
540
- parentIssueNumber: input.parentIssue.number,
541
- sessionId,
542
- event,
543
- }),
544
- },
545
- });
546
- if (publishability.status === 'blocked') {
547
- const reworkDecision = decideImplementationRework({
548
- reasons: publishability.reasons,
549
- config: input.config,
550
- attempt,
551
- });
552
- reworkAttempts.push({
553
- attempt,
554
- maxAttempts: 'maxAttempts' in reworkDecision ? reworkDecision.maxAttempts : undefined,
555
- decisionKind: reworkDecision.kind,
556
- reasons: publishability.reasons,
557
- promptPath,
558
- reportPath,
559
- logPath,
560
- snapshotPath,
561
- });
562
- if (reworkDecision.kind === 'retry') {
563
- rework = reworkDecision.rework;
564
- await safeAppendEvent(input.events, {
565
- timestamp: attemptNow,
566
- issueNumber: childIssueNumber,
567
- parentIssueNumber: input.parentIssue.number,
568
- mode: 'tree-child',
569
- sessionId,
570
- phase: 'tree-child',
571
- status: 'needs-rework',
572
- summary: `Runner scheduled tree-child rework attempt #${reworkDecision.nextAttempt}.`,
573
- artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
574
- });
575
- await mkdir(isolatedHomePath, { recursive: true });
576
- continue;
577
- }
578
- await safeAppendEvent(input.events, {
579
- timestamp: attemptNow,
580
- issueNumber: childIssueNumber,
581
- parentIssueNumber: input.parentIssue.number,
582
- mode: 'tree-child',
583
- sessionId,
584
- phase: 'tree-child',
585
- status: 'blocked',
586
- summary: `Runner rework decision: ${reworkDecision.kind}.`,
587
- artifacts: sessionArtifacts(promptPath, reportPath, logPath, snapshotPath),
588
- });
589
- }
590
- break;
591
- }
592
- if (!publishability) {
593
- throw new Error('Runner internal error: missing child publishability result');
594
- }
559
+ workflowPromptText: input.acceptanceProofWorkflowPrompt,
560
+ codexAdapter: input.codexAdapter,
561
+ },
562
+ onAcceptanceProofAttemptEvent: ({ sessionId: attemptSessionId, event }) => appendAcceptanceProofEvent({
563
+ events: input.events,
564
+ issueNumber: childIssueNumber,
565
+ parentIssueNumber: input.parentIssue.number,
566
+ sessionId: attemptSessionId,
567
+ event,
568
+ }),
569
+ });
570
+ const publishability = attemptLoop.publishability;
571
+ sessionId = attemptLoop.sessionId;
572
+ promptPath = attemptLoop.promptPath;
573
+ reportPath = attemptLoop.reportPath;
574
+ logPath = attemptLoop.logPath;
575
+ snapshotPath = attemptLoop.snapshotPath;
576
+ const reworkAttempts = attemptLoop.reworkAttempts;
595
577
  if (publishability.status === 'promotion-requested') {
596
578
  const promotion = publishability.report.promotion;
597
579
  const evidence = buildPromotionAsBlockedHandoffEvidence({
@@ -730,31 +712,37 @@ async function blockFailedBatch(issueAdapter, config, parentIssueNumber, failure
730
712
  if (!failure.child) {
731
713
  continue;
732
714
  }
733
- await issueAdapter.removeLabels(failure.child.issue.number, [config.github.labels.running.name]);
734
- await issueAdapter.addLabels(failure.child.issue.number, [config.github.labels.blocked.name]);
735
- await issueAdapter.postComment(failure.child.issue.number, buildChildBlockedReport({
736
- parentIssueNumber,
737
- childIssueNumber: failure.child.issue.number,
738
- reasons: [failure.message],
739
- details: ['Worktree preserved for maintainer inspection.'],
740
- freshContextReview: failure.freshContextReview,
741
- durableRunSummary: failure.durableRunSummary,
742
- reworkAttempts: failure.reworkAttempts,
743
- acceptanceProof: failure.acceptanceProof,
744
- }));
715
+ await finishBlockedTerminalOutcome({
716
+ issueNumber: failure.child.issue.number,
717
+ config,
718
+ issueAdapter,
719
+ reportComment: buildChildBlockedReport({
720
+ parentIssueNumber,
721
+ childIssueNumber: failure.child.issue.number,
722
+ reasons: [failure.message],
723
+ details: ['Worktree preserved for maintainer inspection.'],
724
+ freshContextReview: failure.freshContextReview,
725
+ durableRunSummary: failure.durableRunSummary,
726
+ reworkAttempts: failure.reworkAttempts,
727
+ acceptanceProof: failure.acceptanceProof,
728
+ }),
729
+ });
745
730
  }
746
731
  for (const result of successfulUnmerged) {
747
- await issueAdapter.removeLabels(result.child.issue.number, [config.github.labels.running.name]);
748
- await issueAdapter.addLabels(result.child.issue.number, [config.github.labels.blocked.name]);
749
- await issueAdapter.postComment(result.child.issue.number, buildChildBlockedReport({
750
- parentIssueNumber,
751
- childIssueNumber: result.child.issue.number,
752
- reasons: ['A sibling child failed before the batch merge; this child branch was not merged.'],
753
- branchName: result.branchName,
754
- worktreePath: result.worktreePath,
755
- durableRunSummary: result.durableRunSummary,
756
- acceptanceProof: result.acceptanceProof,
757
- }));
732
+ await finishBlockedTerminalOutcome({
733
+ issueNumber: result.child.issue.number,
734
+ config,
735
+ issueAdapter,
736
+ reportComment: buildChildBlockedReport({
737
+ parentIssueNumber,
738
+ childIssueNumber: result.child.issue.number,
739
+ reasons: ['A sibling child failed before the batch merge; this child branch was not merged.'],
740
+ branchName: result.branchName,
741
+ worktreePath: result.worktreePath,
742
+ durableRunSummary: result.durableRunSummary,
743
+ acceptanceProof: result.acceptanceProof,
744
+ }),
745
+ });
758
746
  }
759
747
  }
760
748
  async function handleMergeConflict(git, issueAdapter, config, parentIssueNumber, error, childResult, batchResults) {
@@ -766,68 +754,89 @@ async function handleMergeConflict(git, issueAdapter, config, parentIssueNumber,
766
754
  abortMessage = abortError instanceof Error ? abortError.message : 'merge abort failed';
767
755
  }
768
756
  for (const result of batchResults) {
769
- await issueAdapter.removeLabels(result.child.issue.number, [config.github.labels.running.name]);
770
- await issueAdapter.addLabels(result.child.issue.number, [config.github.labels.blocked.name]);
771
- await issueAdapter.postComment(result.child.issue.number, buildChildBlockedReport({
772
- parentIssueNumber,
773
- childIssueNumber: result.child.issue.number,
774
- reasons: [
775
- result.child.issue.number === childResult.child.issue.number
776
- ? `Merge conflict from branch ${childResult.branchName}`
777
- : `A sibling merge conflict stopped the batch before publication: ${childResult.branchName}`,
778
- ],
779
- details: [
780
- `- Parent worktree: ${error.worktreePath}`,
781
- `- Child worktree: ${result.worktreePath}`,
782
- `- Child branch preserved: ${result.branchName}`,
783
- abortMessage ? `- Merge abort also failed: ${abortMessage}` : '- Merge abort completed.',
784
- ],
785
- batchChildren: batchResults.map((batchResult) => ({
786
- issueNumber: batchResult.child.issue.number,
787
- branchName: batchResult.branchName,
788
- })),
789
- gitOutput: error.stderr || error.stdout || 'no git output',
790
- durableRunSummary: result.durableRunSummary,
791
- acceptanceProof: result.acceptanceProof,
792
- }));
757
+ await finishBlockedTerminalOutcome({
758
+ issueNumber: result.child.issue.number,
759
+ config,
760
+ issueAdapter,
761
+ reportComment: buildChildBlockedReport({
762
+ parentIssueNumber,
763
+ childIssueNumber: result.child.issue.number,
764
+ reasons: [
765
+ result.child.issue.number === childResult.child.issue.number
766
+ ? `Merge conflict from branch ${childResult.branchName}`
767
+ : `A sibling merge conflict stopped the batch before publication: ${childResult.branchName}`,
768
+ ],
769
+ details: [
770
+ `- Parent worktree: ${error.worktreePath}`,
771
+ `- Child worktree: ${result.worktreePath}`,
772
+ `- Child branch preserved: ${result.branchName}`,
773
+ abortMessage ? `- Merge abort also failed: ${abortMessage}` : '- Merge abort completed.',
774
+ ],
775
+ batchChildren: batchResults.map((batchResult) => ({
776
+ issueNumber: batchResult.child.issue.number,
777
+ branchName: batchResult.branchName,
778
+ })),
779
+ gitOutput: error.stderr || error.stdout || 'no git output',
780
+ durableRunSummary: result.durableRunSummary,
781
+ acceptanceProof: result.acceptanceProof,
782
+ }),
783
+ });
793
784
  }
794
785
  }
795
786
  async function markCompletedChildrenReviewReady(issueAdapter, config, store, parentIssueNumber, childResults) {
796
787
  for (const childResult of childResults) {
797
- await issueAdapter.removeLabels(childResult.child.issue.number, [config.github.labels.running.name]);
798
- await issueAdapter.addLabels(childResult.child.issue.number, [config.github.labels.review.name]);
799
- await issueAdapter.postComment(childResult.child.issue.number, buildChildReviewReport({ parentIssueNumber, result: childResult }));
800
- await store.removeRun(childResult.child.issue.number);
788
+ if (childResult.recovered) {
789
+ await store.removeRun(childResult.child.issue.number);
790
+ continue;
791
+ }
792
+ await finishReviewReadyCommentTerminalOutcome({
793
+ issueNumber: childResult.child.issue.number,
794
+ config,
795
+ issueAdapter,
796
+ reportComment: buildChildReviewReport({ parentIssueNumber, result: childResult }),
797
+ afterTerminalMutation: () => store.removeRun(childResult.child.issue.number),
798
+ });
801
799
  }
802
800
  }
803
801
  async function blockCompletedChildren(issueAdapter, config, store, parentIssueNumber, childResults, reason, parentWorktreePath) {
804
802
  for (const result of childResults) {
805
- await issueAdapter.removeLabels(result.child.issue.number, [config.github.labels.running.name]);
806
- await issueAdapter.addLabels(result.child.issue.number, [config.github.labels.blocked.name]);
807
- await issueAdapter.postComment(result.child.issue.number, buildChildBlockedReport({
808
- parentIssueNumber,
809
- childIssueNumber: result.child.issue.number,
810
- reasons: [reason],
811
- details: [
812
- `- Merged child branch was not published: ${result.branchName}`,
813
- `- Parent worktree preserved: ${parentWorktreePath}`,
814
- ],
815
- durableRunSummary: result.durableRunSummary,
816
- acceptanceProof: result.acceptanceProof,
817
- }));
818
- await store.removeRun(result.child.issue.number);
803
+ if (result.recovered) {
804
+ continue;
805
+ }
806
+ await finishBlockedTerminalOutcome({
807
+ issueNumber: result.child.issue.number,
808
+ config,
809
+ issueAdapter,
810
+ reportComment: buildChildBlockedReport({
811
+ parentIssueNumber,
812
+ childIssueNumber: result.child.issue.number,
813
+ reasons: [reason],
814
+ details: [
815
+ `- Merged child branch was not published: ${result.branchName}`,
816
+ `- Parent worktree preserved: ${parentWorktreePath}`,
817
+ ],
818
+ durableRunSummary: result.durableRunSummary,
819
+ acceptanceProof: result.acceptanceProof,
820
+ }),
821
+ afterTerminalMutation: () => store.removeRun(result.child.issue.number),
822
+ });
819
823
  }
820
824
  }
821
- async function finishPlanBlocked(issueAdapter, config, events, result, reasons, mutatedChildren) {
822
- const reportComment = buildParentBlockedReport({
825
+ async function finishPlanBlocked(issueAdapter, config, events, result, reasons, mutatedChildren, commentMarker) {
826
+ const reportBody = buildParentBlockedReport({
823
827
  parentIssueNumber: result.parentIssueNumber,
824
828
  reasons,
825
829
  logPath: result.logPath,
826
830
  mutatedChildren,
827
831
  });
828
- await issueAdapter.removeLabels(result.parentIssueNumber, [config.github.labels.running.name]);
829
- await issueAdapter.addLabels(result.parentIssueNumber, [config.github.labels.blocked.name]);
830
- await issueAdapter.postComment(result.parentIssueNumber, reportComment);
832
+ const reportComment = commentMarker ? `<!-- codex-orchestrator:${commentMarker} -->\n${reportBody}` : reportBody;
833
+ await finishBlockedTerminalOutcome({
834
+ issueNumber: result.parentIssueNumber,
835
+ config,
836
+ issueAdapter,
837
+ reportComment,
838
+ skipCommentIfIncludes: commentMarker,
839
+ });
831
840
  await safeAppendEvent(events, {
832
841
  issueNumber: result.parentIssueNumber,
833
842
  mode: 'plan-parent',