codex-orchestrator 2.0.10 → 2.0.11

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 (109) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +26 -1
  3. package/dist/src/index.d.ts +3 -2
  4. package/dist/src/index.d.ts.map +1 -1
  5. package/dist/src/index.js +1 -1
  6. package/dist/src/index.js.map +1 -1
  7. package/dist/src/v2/acceptance-proof.d.ts +29 -16
  8. package/dist/src/v2/acceptance-proof.d.ts.map +1 -1
  9. package/dist/src/v2/acceptance-proof.js +159 -129
  10. package/dist/src/v2/acceptance-proof.js.map +1 -1
  11. package/dist/src/v2/adapters/command.d.ts +1 -0
  12. package/dist/src/v2/adapters/command.d.ts.map +1 -1
  13. package/dist/src/v2/adapters/command.js +1 -1
  14. package/dist/src/v2/adapters/command.js.map +1 -1
  15. package/dist/src/v2/adapters/gh-pull-request-adapter.js +1 -0
  16. package/dist/src/v2/adapters/gh-pull-request-adapter.js.map +1 -1
  17. package/dist/src/v2/adapters/pull-requests.d.ts +1 -0
  18. package/dist/src/v2/adapters/pull-requests.d.ts.map +1 -1
  19. package/dist/src/v2/adapters/pull-requests.js +1 -0
  20. package/dist/src/v2/adapters/pull-requests.js.map +1 -1
  21. package/dist/src/v2/adapters/worktree.js +3 -3
  22. package/dist/src/v2/adapters/worktree.js.map +1 -1
  23. package/dist/src/v2/atomic-store.d.ts +15 -0
  24. package/dist/src/v2/atomic-store.d.ts.map +1 -1
  25. package/dist/src/v2/atomic-store.js +55 -8
  26. package/dist/src/v2/atomic-store.js.map +1 -1
  27. package/dist/src/v2/candidate.d.ts +152 -0
  28. package/dist/src/v2/candidate.d.ts.map +1 -0
  29. package/dist/src/v2/candidate.js +129 -0
  30. package/dist/src/v2/candidate.js.map +1 -0
  31. package/dist/src/v2/checked-change.d.ts +37 -6
  32. package/dist/src/v2/checked-change.d.ts.map +1 -1
  33. package/dist/src/v2/checked-change.js +71 -0
  34. package/dist/src/v2/checked-change.js.map +1 -1
  35. package/dist/src/v2/cli.d.ts +6 -0
  36. package/dist/src/v2/cli.d.ts.map +1 -1
  37. package/dist/src/v2/cli.js +57 -21
  38. package/dist/src/v2/cli.js.map +1 -1
  39. package/dist/src/v2/codex-process.d.ts +1 -0
  40. package/dist/src/v2/codex-process.d.ts.map +1 -1
  41. package/dist/src/v2/codex-process.js +12 -1
  42. package/dist/src/v2/codex-process.js.map +1 -1
  43. package/dist/src/v2/contained-report-operation.d.ts +196 -30
  44. package/dist/src/v2/contained-report-operation.d.ts.map +1 -1
  45. package/dist/src/v2/contained-report-operation.js +464 -150
  46. package/dist/src/v2/contained-report-operation.js.map +1 -1
  47. package/dist/src/v2/direct-delivery.d.ts +10 -28
  48. package/dist/src/v2/direct-delivery.d.ts.map +1 -1
  49. package/dist/src/v2/direct-delivery.js +44 -114
  50. package/dist/src/v2/direct-delivery.js.map +1 -1
  51. package/dist/src/v2/implementation-reviewer.d.ts +4 -24
  52. package/dist/src/v2/implementation-reviewer.d.ts.map +1 -1
  53. package/dist/src/v2/implementation-reviewer.js +29 -35
  54. package/dist/src/v2/implementation-reviewer.js.map +1 -1
  55. package/dist/src/v2/owner-control-lock.d.ts +13 -1
  56. package/dist/src/v2/owner-control-lock.d.ts.map +1 -1
  57. package/dist/src/v2/owner-control-lock.js +66 -7
  58. package/dist/src/v2/owner-control-lock.js.map +1 -1
  59. package/dist/src/v2/proof-report.d.ts +2 -1
  60. package/dist/src/v2/proof-report.d.ts.map +1 -1
  61. package/dist/src/v2/proof-report.js +6 -40
  62. package/dist/src/v2/proof-report.js.map +1 -1
  63. package/dist/src/v2/proof-store.d.ts +19 -7
  64. package/dist/src/v2/proof-store.d.ts.map +1 -1
  65. package/dist/src/v2/proof-store.js +162 -41
  66. package/dist/src/v2/proof-store.js.map +1 -1
  67. package/dist/src/v2/review-feedback-coordinator.d.ts +7 -1
  68. package/dist/src/v2/review-feedback-coordinator.d.ts.map +1 -1
  69. package/dist/src/v2/review-feedback-coordinator.js +97 -70
  70. package/dist/src/v2/review-feedback-coordinator.js.map +1 -1
  71. package/dist/src/v2/review-feedback.d.ts +0 -18
  72. package/dist/src/v2/review-feedback.d.ts.map +1 -1
  73. package/dist/src/v2/review-feedback.js +9 -34
  74. package/dist/src/v2/review-feedback.js.map +1 -1
  75. package/dist/src/v2/route-coordinator.d.ts +7 -12
  76. package/dist/src/v2/route-coordinator.d.ts.map +1 -1
  77. package/dist/src/v2/route-coordinator.js +33 -128
  78. package/dist/src/v2/route-coordinator.js.map +1 -1
  79. package/dist/src/v2/route-decision.d.ts +0 -9
  80. package/dist/src/v2/route-decision.d.ts.map +1 -1
  81. package/dist/src/v2/route-decision.js +7 -27
  82. package/dist/src/v2/route-decision.js.map +1 -1
  83. package/dist/src/v2/run-issue.d.ts +64 -26
  84. package/dist/src/v2/run-issue.d.ts.map +1 -1
  85. package/dist/src/v2/run-issue.js +1298 -620
  86. package/dist/src/v2/run-issue.js.map +1 -1
  87. package/dist/src/v2/run-store.d.ts +39 -25
  88. package/dist/src/v2/run-store.d.ts.map +1 -1
  89. package/dist/src/v2/run-store.js +469 -88
  90. package/dist/src/v2/run-store.js.map +1 -1
  91. package/dist/src/v2/runtime.d.ts +61 -36
  92. package/dist/src/v2/runtime.d.ts.map +1 -1
  93. package/dist/src/v2/runtime.js +992 -402
  94. package/dist/src/v2/runtime.js.map +1 -1
  95. package/dist/src/v2/spec-coordinator.d.ts +16 -28
  96. package/dist/src/v2/spec-coordinator.d.ts.map +1 -1
  97. package/dist/src/v2/spec-coordinator.js +81 -43
  98. package/dist/src/v2/spec-coordinator.js.map +1 -1
  99. package/dist/src/v2/spec-delivery.d.ts +3 -34
  100. package/dist/src/v2/spec-delivery.d.ts.map +1 -1
  101. package/dist/src/v2/spec-delivery.js +42 -107
  102. package/dist/src/v2/spec-delivery.js.map +1 -1
  103. package/docs/deep-dive.md +52 -14
  104. package/internal-workflow/evals/coding-skill-evals.json +58 -17
  105. package/internal-workflow/manifest.json +1 -1
  106. package/internal-workflow/schemas/proof-report-v1.json +1 -1
  107. package/internal-workflow/skills/code-review/SKILL.md +18 -0
  108. package/internal-workflow/skills/spec-implementer/SKILL.md +8 -1
  109. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
- import { resolve } from 'node:path';
1
+ import { dirname, resolve } from 'node:path';
2
2
  import { checkedChangePayloadSha256, } from './checked-change.js';
3
3
  import { parseAgentAutoConfig } from './config.js';
4
4
  import { canonicalJson, sha256 } from './containment.js';
5
5
  import { validateImplementationReport } from './implementation-report.js';
6
- import { acceptApprovedDirectReview, acceptNeedsWorkDirectReview, beginDirectReviewRepair, canRecoverTerminalDirectReviewReport, createInitialDirectReview, directReviewClosureRequestSha256, directReviewTargetFingerprint, launchDirectReviewInvocation, MAX_DIRECT_REVIEW_REPORT_REPAIRS, prepareDirectReviewClosure, prepareDirectReviewInvocation, projectTerminalDirectReview, recoverTerminalDirectReviewReport, } from './direct-delivery.js';
7
- import { ProofLaunchAuthorizationError, ProofQuiescenceError } from './acceptance-proof.js';
6
+ import { acceptApprovedDirectReview, acceptNeedsWorkDirectReview, beginDirectReviewRepair, createInitialDirectReview, directReviewCandidateTargetFingerprint, directReviewClosureRequestSha256, MAX_DIRECT_REVIEW_REPORT_REPAIRS, prepareDirectReviewClosure, projectTerminalDirectReview, } from './direct-delivery.js';
7
+ import { CandidateProofInspectionError, ProofLaunchAuthorizationError } from './acceptance-proof.js';
8
8
  import { CheckProcessQuiescenceError, resolveIssueCheckPolicy } from './issue-check-policy.js';
9
9
  import { RouteInitializationUnrecoverableError, WorkflowGenerationUnrecoverableError, } from './run-store.js';
10
10
  import { initialRouteExecution, } from './route-coordinator.js';
11
+ import { reserveSpecAuthorSession, reserveSpecReviewerSession } from './spec-delivery.js';
11
12
  import { downstreamLifecycleForRoute, validateRouteTransition, validateTrustedAnswerResumeTransition, } from './route-decision.js';
12
13
  import { activateReviewFeedback, blockReviewFeedback, bootstrapReviewFeedback, markReviewFeedbackPublishing, markReviewFeedbackRepairing, markReviewFeedbackVerified, projectReviewFeedbackBatch, publishReviewFeedback, reserveNextReviewFeedbackRound, } from './review-feedback.js';
13
14
  export class OwnerLockSafetyError extends Error {
@@ -71,6 +72,7 @@ export class RunIssue {
71
72
  }
72
73
  if (comments.length === 0) {
73
74
  try {
75
+ await this.markV3ExternalEffect(active);
74
76
  await this.dependencies.issues.postComment(issueNumber, body);
75
77
  }
76
78
  catch {
@@ -105,6 +107,7 @@ export class RunIssue {
105
107
  return { result: await this.publicationDiverged(active, 'claim-labels-intent-diverged') };
106
108
  }
107
109
  try {
110
+ await this.markV3ExternalEffect(active);
108
111
  await this.dependencies.issues.setLabels(issueNumber, expectedLabels);
109
112
  }
110
113
  catch {
@@ -122,7 +125,99 @@ export class RunIssue {
122
125
  const { runId, branchName, worktreePath, baseSha } = active.record;
123
126
  const message = `feat: implement #${issueNumber}`;
124
127
  let commitSha = await this.dependencies.git.getHead(worktreePath);
125
- if (active.record.intent?.kind === 'commit' || !active.record.intent) {
128
+ const candidateBinding = active.record.candidateBinding;
129
+ if (candidateBinding) {
130
+ const candidate = this.dependencies.git.candidateV2;
131
+ if (!candidate)
132
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-git-v2-required');
133
+ if (!active.record.intent) {
134
+ if (commitSha !== candidateBinding.expectedHeadSha)
135
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-branch-diverged-without-intent');
136
+ const recaptured = await candidate.captureAndPin({
137
+ worktreePath,
138
+ expectedHeadSha: candidateBinding.expectedHeadSha,
139
+ runId,
140
+ boundary: { kind: 'implementation-cycle', cycle: active.record.cycle },
141
+ artifactDir: config.proof.artifactDir,
142
+ });
143
+ if (recaptured.kind === 'failed')
144
+ return this.mapCandidateFailure(active, recaptured.code);
145
+ if (recaptured.value.bindingId !== candidateBinding.bindingId
146
+ || recaptured.value.candidateTreeSha !== candidateBinding.candidateTreeSha
147
+ || !sameStrings(recaptured.value.canonicalChangedFiles, candidateBinding.canonicalChangedFiles)) {
148
+ if (recaptured.value.bindingId !== candidateBinding.bindingId) {
149
+ await candidate.releasePin({ binding: recaptured.value, expectedPinnedCommitSha: recaptured.value.candidateCommitSha });
150
+ }
151
+ if (this.repairBudgetExhausted(active, config.runner.maxCycles)) {
152
+ const released = await this.clearAndReleaseCandidate(active);
153
+ return 'status' in released
154
+ ? released
155
+ : this.terminal(released.active, { status: 'blocked', kind: 'exhausted', resumable: true }, 'candidate-worktree-drift');
156
+ }
157
+ const reopened = await this.startNextCycleFromCandidate(active, ['The issue worktree changed after proof; review the new candidate.']);
158
+ if ('status' in reopened)
159
+ return reopened;
160
+ return this.invokedFailure(reopened, 'candidate-worktree-drift', 'The mutable issue worktree changed after proof; the next bounded repair cycle is ready.');
161
+ }
162
+ active = await this.persist(active, { intent: {
163
+ kind: 'commit',
164
+ parentSha: candidateBinding.expectedHeadSha,
165
+ treeSha: candidateBinding.candidateTreeSha,
166
+ message,
167
+ candidateRef: candidateBinding.candidateRef,
168
+ } });
169
+ }
170
+ const intent = active.record.intent;
171
+ if (intent?.kind !== 'commit' || intent.parentSha !== candidateBinding.expectedHeadSha
172
+ || intent.treeSha !== candidateBinding.candidateTreeSha || intent.message !== message
173
+ || intent.candidateRef !== candidateBinding.candidateRef) {
174
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-commit-intent-diverged');
175
+ }
176
+ if (this.signal.aborted)
177
+ return await this.persistRetainedCommitIntentTerminal(active, 'candidate-publication-cancelled');
178
+ if (!await this.authorized(active, config))
179
+ return await this.persistRetainedCommitIntentTerminal(active, 'candidate-publication-authority-revoked');
180
+ await this.markV3ExternalEffect(active);
181
+ const publication = await candidate.createOrObserveCommit({
182
+ worktreePath,
183
+ branchName,
184
+ parentSha: intent.parentSha,
185
+ treeSha: intent.treeSha,
186
+ message: intent.message,
187
+ candidateRef: intent.candidateRef,
188
+ });
189
+ if (publication.kind === 'failed') {
190
+ return publication.code === 'candidate-ref-update-unknown'
191
+ ? this.persistTerminal(active, { status: 'transport-failed', resumable: false }, publication.code, true)
192
+ : this.mapCandidateFailure(active, publication.code);
193
+ }
194
+ if (publication.value.kind === 'parent-unchanged')
195
+ return this.invokedFailure(active, 'candidate-branch-update-not-observed');
196
+ if (publication.value.kind === 'branch-diverged')
197
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-branch-diverged');
198
+ commitSha = publication.value.sha;
199
+ const normalized = await candidate.normalizeSharedIndex({ worktreePath, expectedHeadSha: commitSha });
200
+ if (normalized.kind === 'failed')
201
+ return this.mapCandidateFailure(active, normalized.code);
202
+ const residual = await this.dependencies.git.listChangedFilesIgnoringUntrackedRoot(worktreePath, config.proof.artifactDir);
203
+ if (residual.length > 0)
204
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-residual-worktree-drift');
205
+ try {
206
+ active = await this.persist(active, {
207
+ intent: { kind: 'push', branch: branchName, sha: commitSha },
208
+ changeBindingVersion: undefined,
209
+ candidateBinding: undefined,
210
+ });
211
+ }
212
+ catch {
213
+ throw new PostEffectStateError(active);
214
+ }
215
+ const released = await candidate.releasePin({ binding: candidateBinding, expectedPinnedCommitSha: candidateBinding.candidateCommitSha });
216
+ if (released.kind === 'failed') {
217
+ return this.invokedFailure(active, 'candidate-pin-release-unknown', 'The publication commit is confirmed; orphan reconciliation will retry exact pin cleanup.');
218
+ }
219
+ }
220
+ else if (active.record.intent?.kind === 'commit' || !active.record.intent) {
126
221
  if (!active.record.intent) {
127
222
  if (commitSha === baseSha) {
128
223
  active = await this.persist(active, {
@@ -206,6 +301,7 @@ export class RunIssue {
206
301
  if (!await this.authorized(active, config))
207
302
  return await this.revoked(active);
208
303
  try {
304
+ await this.markV3ExternalEffect(active);
209
305
  await this.dependencies.pullRequests.createDraft({
210
306
  title: `Implement #${issueNumber}: ${issue.title}`,
211
307
  body: prBody,
@@ -246,6 +342,7 @@ export class RunIssue {
246
342
  if (!await this.authorized(active, config))
247
343
  return await this.revoked(active);
248
344
  try {
345
+ await this.markV3ExternalEffect(active);
249
346
  await this.dependencies.issues.postComment(issueNumber, handoffBody);
250
347
  }
251
348
  catch {
@@ -272,6 +369,7 @@ export class RunIssue {
272
369
  if (!await this.authorized(active, config))
273
370
  return await this.revoked(active);
274
371
  try {
372
+ await this.markV3ExternalEffect(active);
275
373
  await this.dependencies.issues.setLabels(issueNumber, terminalLabels);
276
374
  }
277
375
  catch {
@@ -290,7 +388,7 @@ export class RunIssue {
290
388
  ...(active.record.routeReceipt?.route === 'direct' ? {
291
389
  reviewFeedback: bootstrapReviewFeedback(active.record.reviewFeedback ?? {
292
390
  version: 1, phase: 'bootstrap-required', consumedSourceIds: [], previousPublishedHeadSha: null,
293
- repairRound: 0, activeBatch: null, implementationInvocation: null, history: [],
391
+ repairRound: 0, activeBatch: null, history: [],
294
392
  verifiedReceipt: null, terminal: null,
295
393
  }, commitSha, []),
296
394
  } : {}),
@@ -323,7 +421,105 @@ export class RunIssue {
323
421
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-update-baseline-diverged');
324
422
  }
325
423
  }
326
- if (!active.record.intent || active.record.intent.kind === 'review-update-commit') {
424
+ const candidateBinding = active.record.candidateBinding;
425
+ if (candidateBinding) {
426
+ const candidate = this.dependencies.git.candidateV2;
427
+ if (!candidate)
428
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-git-v2-required');
429
+ if (!active.record.intent) {
430
+ const recaptured = await candidate.captureAndPin({
431
+ worktreePath: active.record.worktreePath,
432
+ expectedHeadSha: oldHead,
433
+ runId: active.record.runId,
434
+ boundary: {
435
+ kind: 'review-feedback', batchId: batch.batchId,
436
+ repairRound: feedback.repairRound,
437
+ },
438
+ artifactDir: config.proof.artifactDir,
439
+ });
440
+ if (recaptured.kind === 'failed')
441
+ return this.mapCandidateFailure(active, recaptured.code);
442
+ if (recaptured.value.bindingId !== candidateBinding.bindingId
443
+ || recaptured.value.candidateTreeSha !== candidateBinding.candidateTreeSha
444
+ || !sameStrings(recaptured.value.canonicalChangedFiles, candidateBinding.canonicalChangedFiles)) {
445
+ if (recaptured.value.bindingId !== candidateBinding.bindingId) {
446
+ await candidate.releasePin({ binding: recaptured.value, expectedPinnedCommitSha: recaptured.value.candidateCommitSha });
447
+ }
448
+ const released = await this.clearAndReleaseCandidate(active);
449
+ if ('status' in released)
450
+ return released;
451
+ if (feedback.repairRound >= 3) {
452
+ return this.blockReviewFeedback(released.active, 'exhausted', 'review-feedback-candidate-worktree-drift');
453
+ }
454
+ const reopened = await this.persist(released.active, {
455
+ lifecycle: 'implementing',
456
+ reviewFeedback: reserveNextReviewFeedbackRound(feedback),
457
+ reworkFindings: ['The issue worktree changed after proof; rebuild and revalidate the review-feedback candidate.'],
458
+ checks: [], checkedChangeSha256: undefined, proofId: undefined, proofReceipt: undefined,
459
+ });
460
+ return this.invokedFailure(reopened, 'review-feedback-candidate-worktree-drift', 'The next review-feedback repair round is ready.');
461
+ }
462
+ active = await this.persist(active, { intent: {
463
+ kind: 'review-update-commit', batchId: batch.batchId, parentSha: oldHead,
464
+ treeSha: candidateBinding.candidateTreeSha, message, candidateRef: candidateBinding.candidateRef,
465
+ } });
466
+ }
467
+ const intent = active.record.intent;
468
+ if (intent?.kind !== 'review-update-commit' || intent.batchId !== batch.batchId
469
+ || intent.parentSha !== oldHead || intent.treeSha !== candidateBinding.candidateTreeSha
470
+ || intent.message !== message || intent.candidateRef !== candidateBinding.candidateRef) {
471
+ return this.persistRetainedCommitIntentTerminal(active, 'review-feedback-candidate-intent-diverged');
472
+ }
473
+ if (!await this.authorized(active, config))
474
+ return this.persistRetainedCommitIntentTerminal(active, 'review-feedback-publication-authority-revoked');
475
+ const validation = await coordinator.revalidate({ batch, epoch: 'pre-update', expectedHeadSha: oldHead });
476
+ const validationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-precommit-revalidation-failed');
477
+ if (validationFailure)
478
+ return validationFailure;
479
+ await this.markV3ExternalEffect(active);
480
+ const publication = await candidate.createOrObserveCommit({
481
+ worktreePath: active.record.worktreePath,
482
+ branchName: active.record.branchName,
483
+ parentSha: intent.parentSha,
484
+ treeSha: intent.treeSha,
485
+ message: intent.message,
486
+ candidateRef: intent.candidateRef,
487
+ });
488
+ if (publication.kind === 'failed') {
489
+ return publication.code === 'candidate-ref-update-unknown'
490
+ ? this.persistTerminal(active, { status: 'transport-failed', resumable: false }, publication.code, true)
491
+ : this.mapCandidateFailure(active, publication.code);
492
+ }
493
+ if (publication.value.kind === 'parent-unchanged')
494
+ return this.invokedFailure(active, 'review-feedback-candidate-branch-update-not-observed');
495
+ if (publication.value.kind === 'branch-diverged')
496
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-branch-diverged');
497
+ head = publication.value.sha;
498
+ const normalized = await candidate.normalizeSharedIndex({ worktreePath: active.record.worktreePath, expectedHeadSha: head });
499
+ if (normalized.kind === 'failed')
500
+ return this.mapCandidateFailure(active, normalized.code);
501
+ if ((await this.dependencies.git.listChangedFilesIgnoringUntrackedRoot(active.record.worktreePath, config.proof.artifactDir)).length > 0) {
502
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-residual-worktree-drift');
503
+ }
504
+ try {
505
+ active = await this.persist(active, {
506
+ intent: {
507
+ kind: 'review-update-push', batchId: batch.batchId, branch: active.record.branchName,
508
+ priorRemoteSha: oldHead, sha: head, treeSha: candidateBinding.candidateTreeSha,
509
+ },
510
+ changeBindingVersion: undefined,
511
+ candidateBinding: undefined,
512
+ });
513
+ }
514
+ catch {
515
+ throw new PostEffectStateError(active);
516
+ }
517
+ const released = await candidate.releasePin({ binding: candidateBinding, expectedPinnedCommitSha: candidateBinding.candidateCommitSha });
518
+ if (released.kind === 'failed') {
519
+ return this.invokedFailure(active, 'candidate-pin-release-unknown', 'The review update commit is confirmed; orphan reconciliation will retry exact pin cleanup.');
520
+ }
521
+ }
522
+ else if (!active.record.intent || active.record.intent.kind === 'review-update-commit') {
327
523
  if (!active.record.intent) {
328
524
  active = await this.persist(active, { intent: {
329
525
  kind: 'review-update-commit', batchId: batch.batchId, parentSha: oldHead,
@@ -447,6 +643,7 @@ export class RunIssue {
447
643
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
448
644
  }
449
645
  try {
646
+ await this.markV3ExternalEffect(active);
450
647
  await this.dependencies.pullRequests.postConversationComment(batch.pullRequest.number, body);
451
648
  }
452
649
  catch {
@@ -496,6 +693,7 @@ export class RunIssue {
496
693
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
497
694
  }
498
695
  try {
696
+ await this.markV3ExternalEffect(active);
499
697
  await this.dependencies.issues.setLabels(issueNumber, finalLabels);
500
698
  }
501
699
  catch {
@@ -533,19 +731,31 @@ export class RunIssue {
533
731
  }
534
732
  return publicOutcome(outcome);
535
733
  }
536
- async continueReviewReady(starting, targetRoot, config) {
734
+ async continueReviewReady(starting, targetRoot, config, initialIssue) {
537
735
  const terminal = starting.record.terminalOutcome;
538
736
  const feedback = starting.record.reviewFeedback;
539
737
  if (!terminal || terminal.status !== 'review-ready' || !feedback || !this.dependencies.reviewFeedback
540
738
  || starting.record.routeReceipt?.route !== 'direct' || starting.record.directReview?.status !== 'clear') {
541
739
  return { result: publicOutcome(terminal) };
542
740
  }
543
- const pullRequest = await this.dependencies.pullRequests.findOpen({
544
- headBranch: starting.record.branchName,
545
- baseBranch: config.github.baseBranch,
546
- });
741
+ const reviewLabels = [config.github.labels.review.name];
742
+ const initialAuthority = this.reviewReadyIssueAuthority(initialIssue, starting.record);
743
+ if (!initialAuthority)
744
+ return { result: await this.invokedFailure(starting, 'review-feedback-issue-observation-incomplete') };
745
+ if (!sameStrings(initialAuthority.labels, reviewLabels))
746
+ return { result: publicOutcome(terminal) };
747
+ let pullRequest;
748
+ try {
749
+ pullRequest = await this.dependencies.pullRequests.findOpen({
750
+ headBranch: starting.record.branchName,
751
+ baseBranch: config.github.baseBranch,
752
+ });
753
+ }
754
+ catch {
755
+ return { result: await this.invokedFailure(starting, 'review-feedback-pr-observation-incomplete') };
756
+ }
547
757
  if (!pullRequest?.number || !pullRequest.nodeId || !pullRequest.headSha) {
548
- return { result: await this.reviewReadyObservationBlocked(starting, 'review-feedback-pr-identity-missing') };
758
+ return { result: await this.invokedFailure(starting, 'review-feedback-pr-observation-incomplete') };
549
759
  }
550
760
  const expectedHeadSha = feedback.phase === 'bootstrap-required'
551
761
  ? pullRequest.headSha
@@ -562,12 +772,33 @@ export class RunIssue {
562
772
  expectedBaseRefName: config.github.baseBranch,
563
773
  marker: `<!-- codex-orchestrator:run:${starting.record.runId}:pr -->`,
564
774
  consumedSourceIds: feedback.consumedSourceIds,
775
+ restPullRequest: {
776
+ number: pullRequest.number, nodeId: pullRequest.nodeId, headSha: pullRequest.headSha, body: pullRequest.body,
777
+ },
565
778
  });
566
779
  if (observed.status === 'retryable')
567
780
  return { result: await this.invokedFailure(starting, 'review-feedback-observation-retryable') };
568
781
  if (observed.status === 'blocked') {
569
782
  return { result: await this.reviewReadyObservationBlocked(starting, 'review-feedback-observation-blocked') };
570
783
  }
784
+ const willPersistFeedback = feedback.phase === 'bootstrap-required'
785
+ || (feedback.phase === 'idle' && observed.status === 'frozen');
786
+ if (willPersistFeedback) {
787
+ let currentIssue;
788
+ try {
789
+ currentIssue = await this.readIssue(starting.record.issueNumber);
790
+ }
791
+ catch {
792
+ return { result: await this.invokedFailure(starting, 'review-feedback-issue-observation-incomplete') };
793
+ }
794
+ const currentAuthority = this.reviewReadyIssueAuthority(currentIssue, starting.record);
795
+ if (!currentAuthority || canonicalJson(currentAuthority) !== canonicalJson(initialAuthority)) {
796
+ return { result: await this.invokedFailure(starting, 'review-feedback-issue-observation-torn') };
797
+ }
798
+ if (currentAuthority.state !== 'OPEN' || !currentAuthority.trustedClaim) {
799
+ return { result: await this.reviewReadyObservationBlocked(starting, 'review-feedback-claim-authority-diverged') };
800
+ }
801
+ }
571
802
  if (feedback.phase === 'bootstrap-required') {
572
803
  const sourceIds = observed.status === 'frozen'
573
804
  ? observed.batch.sources.map((source) => source.sourceId)
@@ -579,9 +810,6 @@ export class RunIssue {
579
810
  }
580
811
  if (feedback.phase !== 'idle' || observed.status === 'none')
581
812
  return { result: publicOutcome(terminal) };
582
- if (!await this.authorizedForExactLabels(starting, [config.github.labels.review.name])) {
583
- return { result: publicOutcome(terminal) };
584
- }
585
813
  const projected = projectReviewFeedbackBatch(observed.batch, starting.record.directReview.targetRevision);
586
814
  const repairReview = beginDirectReviewRepair(starting.record.directReview, projected.repairFindings);
587
815
  let active = await this.persist(starting, {
@@ -589,11 +817,10 @@ export class RunIssue {
589
817
  reviewFeedback: activateReviewFeedback(feedback, observed.batch),
590
818
  directReview: {
591
819
  ...repairReview,
592
- review: { ...repairReview.review, reportRepairs: 0, transportRetries: 0 },
820
+ review: { ...repairReview.review, reportRepairs: 0 },
593
821
  },
594
822
  reworkFindings: projected.repairFindings.map((finding) => finding.summary),
595
823
  reportRepairs: 0,
596
- transportRetries: 0,
597
824
  checks: [],
598
825
  checkedChangeSha256: undefined,
599
826
  proofId: undefined,
@@ -609,6 +836,11 @@ export class RunIssue {
609
836
  });
610
837
  return this.resumeActivatedReviewFeedback(active, targetRoot, config);
611
838
  }
839
+ reviewReadyIssueAuthority(issue, record) {
840
+ if (!issue)
841
+ return undefined;
842
+ return { state: issue.state, labels: sortedUnique(issue.labels), trustedClaim: this.hasTrustedClaim(issue, record) };
843
+ }
612
844
  async resumeActivatedReviewFeedback(starting, targetRoot, config) {
613
845
  let active = starting;
614
846
  const feedback = active.record.reviewFeedback;
@@ -642,6 +874,7 @@ export class RunIssue {
642
874
  return { result: validationFailure };
643
875
  if (labelsAreReview) {
644
876
  try {
877
+ await this.markV3ExternalEffect(active);
645
878
  await this.dependencies.issues.setLabels(active.record.issueNumber, runningLabels);
646
879
  }
647
880
  catch {
@@ -707,7 +940,6 @@ export class RunIssue {
707
940
  async runIssue(input) {
708
941
  let owner;
709
942
  let active;
710
- let recoveredFeedbackImplementation;
711
943
  try {
712
944
  assertPositiveInteger(input.issueNumber, 'issueNumber');
713
945
  const targetRoot = resolve(input.targetRoot);
@@ -734,12 +966,16 @@ export class RunIssue {
734
966
  return await this.preClaimInternal('state-write-failed', input.issueNumber);
735
967
  }
736
968
  const confirmedConfig = await this.readStrictConfig(targetRoot);
737
- if (!confirmedConfig.bytes.equals(initialConfig.bytes)
738
- || canonicalRepository !== `${confirmedConfig.config.github.owner.toLowerCase()}/${confirmedConfig.config.github.repo.toLowerCase()}`) {
969
+ if (!confirmedConfig.bytes.equals(initialConfig.bytes) || canonicalRepository !== `${confirmedConfig.config.github.owner.toLowerCase()}/${confirmedConfig.config.github.repo.toLowerCase()}`) {
739
970
  return await this.preClaimInternal('config-changed-during-owner-acquire', input.issueNumber);
740
971
  }
741
972
  const config = confirmedConfig.config;
742
- if (this.signal.aborted)
973
+ const persisted = await this.dependencies.runRecords.read();
974
+ const matchingRuns = persisted.runs.filter((run) => run.issueNumber === input.issueNumber && run.canonicalRepository === canonicalRepository);
975
+ if (matchingRuns.length > 1)
976
+ return await this.preClaimInternal('ambiguous-run-state', input.issueNumber);
977
+ const existing = matchingRuns[0];
978
+ if (this.signal.aborted && existing?.lifecycle !== 'proving')
743
979
  return await this.preClaimCancelled(input.issueNumber);
744
980
  let issue;
745
981
  try {
@@ -748,11 +984,24 @@ export class RunIssue {
748
984
  catch {
749
985
  return await this.preClaimTransport(input.issueNumber);
750
986
  }
751
- const persisted = await this.dependencies.runRecords.read();
752
- const matchingRuns = persisted.runs.filter((run) => run.issueNumber === input.issueNumber && run.canonicalRepository === canonicalRepository);
753
- if (matchingRuns.length > 1)
754
- return await this.preClaimInternal('ambiguous-run-state', input.issueNumber);
755
- const existing = matchingRuns[0];
987
+ if (this.dependencies.git.candidateV2?.reconcileOrphans) {
988
+ const reconciled = await this.dependencies.git.candidateV2.reconcileOrphans({
989
+ repositoryRoot: targetRoot,
990
+ workspaceRoot: resolve(targetRoot, config.runner.workspaceRoot),
991
+ activeCandidateRefs: persisted.runs.flatMap((run) => run.candidateBinding ? [run.candidateBinding.candidateRef] : []),
992
+ pendingCandidates: persisted.runs.flatMap((run) => {
993
+ const boundary = pendingCandidateBoundary(run);
994
+ return !run.terminalOutcome && !run.candidateBinding && boundary
995
+ ? [{ runId: run.runId, worktreePath: run.worktreePath, expectedHeadSha: expectedImplementationHead(run), boundary, artifactDir: config.proof.artifactDir }]
996
+ : [];
997
+ }),
998
+ activeExecutions: persisted.runs.flatMap((run) => run.executionLease
999
+ ? [{ path: run.executionLease.path, candidateCommitSha: run.executionLease.candidateCommitSha }]
1000
+ : []),
1001
+ });
1002
+ if (reconciled.kind === 'failed')
1003
+ return this.preClaimTransport(input.issueNumber);
1004
+ }
756
1005
  let issueSnapshot;
757
1006
  let frozenCriteria;
758
1007
  let runId;
@@ -787,15 +1036,21 @@ export class RunIssue {
787
1036
  && blockedLabelProjection(issue.labels, config).status !== 'settled') {
788
1037
  return await this.reconcilePersistedBlockedTerminal(active, issue, existing.terminalOutcome);
789
1038
  }
790
- if (existing.terminalOutcome) {
791
- if (existing.terminalOutcome.status !== 'review-ready') {
792
- const recovered = await this.recoverTerminalReviewReport(active, issue, config, frozenCriteria);
793
- if (!recovered)
794
- return publicOutcome(existing.terminalOutcome);
795
- active = recovered;
1039
+ if (active.record.terminalOutcome?.status === 'transport-failed'
1040
+ && !active.record.terminalOutcome.resumable
1041
+ && active.record.candidateBinding
1042
+ && (active.record.intent?.kind === 'commit' || active.record.intent?.kind === 'review-update-commit')) {
1043
+ const reconciled = await this.reconcileUnknownCandidatePublication(active);
1044
+ if ('status' in reconciled)
1045
+ return reconciled;
1046
+ active = reconciled.active;
1047
+ }
1048
+ if (active.record.terminalOutcome) {
1049
+ if (active.record.terminalOutcome.status !== 'review-ready') {
1050
+ return publicOutcome(active.record.terminalOutcome);
796
1051
  }
797
1052
  else {
798
- const continuation = await this.continueReviewReady(active, targetRoot, config);
1053
+ const continuation = await this.continueReviewReady(active, targetRoot, config, issue);
799
1054
  if ('result' in continuation)
800
1055
  return continuation.result;
801
1056
  active = continuation.active;
@@ -803,18 +1058,42 @@ export class RunIssue {
803
1058
  frozenCriteria = structuredClone(active.record.frozenCriteria);
804
1059
  }
805
1060
  }
1061
+ const canonicalReviewRecovery = active.record.executionLease?.operation === 'direct-review'
1062
+ && active.record.reportInvocation?.operation === 'code-review';
1063
+ const canonicalProofRecovery = active.record.lifecycle === 'proving'
1064
+ && active.record.executionLease?.operation === 'acceptance-proof';
1065
+ if (active.record.executionLease && !canonicalReviewRecovery && !canonicalProofRecovery) {
1066
+ const reconciled = await this.reconcilePersistedCandidateExecution(active, config);
1067
+ if ('status' in reconciled)
1068
+ return reconciled;
1069
+ active = reconciled.active;
1070
+ }
1071
+ if (active.state.version === 2 && active.record.lifecycle === 'proving'
1072
+ && active.record.routeReceipt?.route === 'direct') {
1073
+ const cutover = await this.cutoverLegacyImmutableBoundary(active, config, frozenCriteria);
1074
+ if ('status' in cutover)
1075
+ return cutover;
1076
+ active = cutover.active;
1077
+ }
1078
+ else if (active.state.version === 2 && active.record.lifecycle === 'checking'
1079
+ && active.record.routeReceipt?.route === 'direct') {
1080
+ const cutover = await this.cutoverLegacyImmutableBoundary(active, config, frozenCriteria);
1081
+ if ('status' in cutover)
1082
+ return cutover;
1083
+ active = cutover.active;
1084
+ }
806
1085
  if (active.record.reviewFeedback?.phase === 'frozen') {
807
1086
  const activation = await this.resumeActivatedReviewFeedback(active, targetRoot, config);
808
1087
  if ('result' in activation)
809
1088
  return activation.result;
810
1089
  active = activation.active;
811
1090
  }
812
- if (existing.lifecycle === 'publishing') {
1091
+ if (active.record.lifecycle === 'publishing') {
813
1092
  return active.record.reviewFeedback?.activeBatch
814
1093
  ? await this.updateExistingPullRequest(active, config, input.issueNumber)
815
1094
  : await this.publish(active, config, issueSnapshot, input.issueNumber);
816
1095
  }
817
- if (existing.lifecycle === 'claimed') {
1096
+ if (active.record.lifecycle === 'claimed') {
818
1097
  const claim = await this.reconcileClaim(active, config);
819
1098
  if ('result' in claim)
820
1099
  return claim.result;
@@ -841,19 +1120,7 @@ export class RunIssue {
841
1120
  issueSnapshot = structuredClone(active.record.issueSnapshot);
842
1121
  }
843
1122
  else {
844
- if (existing.lifecycle === 'safe-halt') {
845
- const process = existing.process;
846
- if (!process)
847
- return await this.publicationDiverged(active, 'safe-halt-process-missing');
848
- try {
849
- await this.dependencies.waitForReviewProcessAbsence(process.processGroupId);
850
- }
851
- catch {
852
- return await this.invokedFailure(active, 'safe-halt-process-absence-unconfirmed');
853
- }
854
- active = await this.persist(active, { lifecycle: process.resumeLifecycle, process: undefined });
855
- }
856
- if (existing.lifecycle === 'waiting-human') {
1123
+ if (active.record.lifecycle === 'waiting-human') {
857
1124
  const waiting = await this.continueWaitingHuman(active);
858
1125
  if ('result' in waiting)
859
1126
  return waiting.result;
@@ -861,46 +1128,15 @@ export class RunIssue {
861
1128
  issueSnapshot = structuredClone(active.record.issueSnapshot);
862
1129
  frozenCriteria = structuredClone(active.record.frozenCriteria);
863
1130
  }
864
- if (existing.lifecycle === 'spec-authoring') {
1131
+ if (active.record.lifecycle === 'spec-authoring') {
865
1132
  if (!await this.authorized(active, config))
866
1133
  return await this.revoked(active);
867
1134
  return await this.continueSpecRequired(active);
868
1135
  }
869
- const feedbackInvocation = active.record.reviewFeedback?.implementationInvocation;
870
- if (feedbackInvocation?.phase === 'prepared') {
871
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-launch-ambiguous');
872
- }
873
- if (feedbackInvocation?.phase === 'launched') {
874
- try {
875
- await this.dependencies.waitForReviewProcessAbsence(feedbackInvocation.processGroupId);
876
- }
877
- catch {
878
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-process-absence-unconfirmed');
879
- }
880
- if (!this.dependencies.implementationAgent.recover) {
881
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-report-recovery-unavailable');
882
- }
883
- try {
884
- recoveredFeedbackImplementation = await this.dependencies.implementationAgent.recover({
885
- runId: active.record.runId,
886
- canonicalRepository: active.record.canonicalRepository,
887
- invocation: feedbackInvocation,
888
- });
889
- }
890
- catch {
891
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-report-recovery-failed');
892
- }
893
- if (!recoveredFeedbackImplementation) {
894
- const snapshot = await this.dependencies.git.snapshot(active.record.worktreePath);
895
- if (!sameFreshness(snapshot, feedbackInvocation.baseline)) {
896
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-worktree-ambiguous');
897
- }
898
- }
899
- }
900
1136
  if (!['triaging', 'routed', 'implementing', 'reworking', 'checking', 'proving'].includes(active.record.lifecycle)) {
901
1137
  return await this.terminal(active, { status: 'internal-error', code: 'resume-phase-not-reconciled' });
902
1138
  }
903
- if (!await this.authorized(active, config))
1139
+ if (active.record.lifecycle !== 'proving' && !await this.authorized(active, config))
904
1140
  return await this.revoked(active);
905
1141
  if (active.record.lifecycle !== 'triaging' && active.record.lifecycle !== 'routed') {
906
1142
  if (!active.record.routeExecution || !active.record.routeReceipt)
@@ -911,12 +1147,13 @@ export class RunIssue {
911
1147
  && active.record.directReview?.status === 'clear';
912
1148
  const proofRecovery = active.record.lifecycle === 'proving'
913
1149
  && active.record.directReview?.status === 'clear';
914
- const feedbackRepair = active.record.reviewFeedback?.phase === 'repairing'
915
- && active.record.directReview?.stage === 'review-repair';
1150
+ const directReviewRepair = active.record.lifecycle === 'implementing'
1151
+ && active.record.directReview?.status === 'active'
1152
+ && active.record.directReview.stage === 'review-repair';
1153
+ const mutableRecovery = active.record.lifecycle === 'implementing' && active.record.mutableInvocation !== undefined;
916
1154
  const qualificationRecovery = active.record.lifecycle === 'implementing'
917
- && !active.record.directReview
918
- && !this.hasRecordedImplementationStart(active.record, config);
919
- if (!reviewRecovery && !checkRecovery && !proofRecovery && !feedbackRepair && !qualificationRecovery) {
1155
+ && !active.record.directReview;
1156
+ if (!reviewRecovery && !checkRecovery && !proofRecovery && !directReviewRepair && !qualificationRecovery && !mutableRecovery) {
920
1157
  if (this.repairBudgetExhausted(active, config.runner.maxCycles)) {
921
1158
  return await this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true });
922
1159
  }
@@ -937,7 +1174,12 @@ export class RunIssue {
937
1174
  assertUuid(runId);
938
1175
  branchName = `codex/issue-${input.issueNumber}`;
939
1176
  worktreePath = resolve(targetRoot, config.runner.workspaceRoot, `issue-${input.issueNumber}`);
940
- baseSha = await this.dependencies.git.getBaseSha({ targetRoot, baseBranch: config.github.baseBranch });
1177
+ try {
1178
+ baseSha = await this.dependencies.git.getBaseSha({ targetRoot, baseBranch: config.github.baseBranch });
1179
+ }
1180
+ catch {
1181
+ return await this.preClaimTransport(input.issueNumber, 'base-refresh-failed', 'The configured remote base branch could not be refreshed before claim.');
1182
+ }
941
1183
  assertGitSha(baseSha, 'baseSha');
942
1184
  const claimBody = claimComment(runId, input.issueNumber, branchName);
943
1185
  active = await this.createRun({
@@ -972,28 +1214,51 @@ export class RunIssue {
972
1214
  if (active.record.lifecycle === 'checking' && !active.record.directReview && active.record.routeReceipt?.route === 'direct') {
973
1215
  return await this.terminal(active, { status: 'internal-error', code: 'direct-review-state-missing' });
974
1216
  }
1217
+ if (active.record.routeReceipt?.route === 'direct' && !this.dependencies.git.candidateV2) {
1218
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-git-v2-required');
1219
+ }
975
1220
  if (active.record.lifecycle === 'implementing' && active.record.routeReceipt?.route === 'direct'
976
- && !active.record.directReview) {
1221
+ && !active.record.directReview
1222
+ && (!active.record.mutableInvocation || active.record.mutableInvocation.operation === 'qualification-repair')) {
977
1223
  const qualification = await this.qualifyChecks(active, config, issueSnapshot, frozenCriteria);
978
1224
  if ('status' in qualification)
979
1225
  return qualification;
980
1226
  active = qualification.active;
981
1227
  }
982
1228
  attemptLoop: while (true) {
983
- if (!await this.authorized(active, config)) {
1229
+ if (active.record.lifecycle !== 'proving' && !await this.authorized(active, config))
984
1230
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
985
- }
986
- if (this.signal.aborted)
1231
+ if (this.signal.aborted && active.record.lifecycle !== 'proving')
987
1232
  return await this.terminal(active, { status: 'cancelled' });
988
1233
  let resumeAtChecks = (active.record.lifecycle === 'checking' || active.record.lifecycle === 'proving')
989
1234
  && active.record.directReview?.status === 'clear';
990
1235
  if (active.record.lifecycle === 'implementing'
991
1236
  && active.record.directReview?.status === 'active'
992
1237
  && (active.record.directReview.stage === 'review-full' || active.record.directReview.stage === 'review-closure')) {
993
- const recovered = await this.recoverDirectReviewInvocation(active);
994
- if ('status' in recovered)
995
- return recovered;
996
- active = recovered;
1238
+ const recoveryBoundary = active.record.reviewFeedback?.activeBatch
1239
+ ? {
1240
+ kind: 'review-feedback',
1241
+ batchId: active.record.reviewFeedback.activeBatch.batchId,
1242
+ repairRound: active.record.reviewFeedback.repairRound,
1243
+ }
1244
+ : { kind: 'implementation-cycle', cycle: active.record.cycle };
1245
+ const captured = await this.ensureCandidateBinding(active, config, recoveryBoundary);
1246
+ if ('status' in captured)
1247
+ return captured;
1248
+ active = captured.active;
1249
+ const binding = active.record.candidateBinding;
1250
+ const candidateTargetFingerprint = directReviewCandidateTargetFingerprint({
1251
+ binding,
1252
+ routeDecisionSha256: active.record.routeReceipt.decisionSha256,
1253
+ workflowGenerationHash: active.record.workflowGeneration.generationHash,
1254
+ cycle: active.record.cycle,
1255
+ frozenCriteria,
1256
+ });
1257
+ if (active.record.directReview.targetFingerprint !== candidateTargetFingerprint) {
1258
+ active = await this.persist(active, {
1259
+ directReview: { ...active.record.directReview, targetFingerprint: candidateTargetFingerprint },
1260
+ });
1261
+ }
997
1262
  const reviewed = await this.runDirectReviewFull(active, publicIssueSnapshot(issueSnapshot), frozenCriteria, `recovered-implementation-cycle-${active.record.cycle}`, config.runner.maxCycles, config);
998
1263
  if ('status' in reviewed)
999
1264
  return reviewed;
@@ -1010,90 +1275,54 @@ export class RunIssue {
1010
1275
  const feedbackProjection = feedbackBatch
1011
1276
  ? projectReviewFeedbackBatch(feedbackBatch, active.record.directReview.targetRevision)
1012
1277
  : undefined;
1278
+ let feedbackImplementationLaunchFailure;
1013
1279
  const implementationLaunch = feedbackBatch ? {
1014
1280
  reviewFeedbackRound: active.record.reviewFeedback.repairRound,
1015
1281
  reviewFeedback: feedbackProjection.workerFeedback,
1016
- onPrepared: async (invocation) => {
1017
- const currentActive = active;
1018
- active = await this.persist(currentActive, {
1019
- reviewFeedback: {
1020
- ...structuredClone(currentActive.record.reviewFeedback),
1021
- implementationInvocation: { ...structuredClone(invocation), phase: 'prepared', pid: null, processGroupId: null, launchedAt: null },
1022
- },
1023
- });
1024
- },
1025
- onLaunched: async (invocation) => {
1026
- const currentActive = active;
1027
- const current = currentActive.record.reviewFeedback?.implementationInvocation;
1028
- if (!current || current.phase !== 'prepared' || current.attemptId !== invocation.attemptId)
1029
- throw new Error('review feedback implementation launch mismatch');
1030
- active = await this.persist(currentActive, {
1031
- reviewFeedback: {
1032
- ...structuredClone(currentActive.record.reviewFeedback),
1033
- implementationInvocation: { ...current, phase: 'launched', pid: invocation.pid, processGroupId: invocation.processGroupId, launchedAt: invocation.launchedAt },
1034
- },
1035
- });
1036
- },
1037
- } : {
1038
- onLaunched: async () => {
1039
- const currentActive = active;
1040
- const qualification = currentActive.record.checkQualification;
1041
- if (!qualification)
1042
- throw new Error('implementation launch requires check qualification');
1043
- active = await this.persist(currentActive, {
1044
- checkQualification: { ...qualification, implementationStarted: true },
1045
- });
1282
+ beforeLaunch: async () => {
1283
+ const failure = await this.revalidateFeedbackWorker(active, config, input.issueNumber);
1284
+ if (failure) {
1285
+ feedbackImplementationLaunchFailure = failure;
1286
+ throw new Error('review feedback implementation launch authorization changed');
1287
+ }
1046
1288
  },
1047
- };
1289
+ } : {};
1048
1290
  const deniedPathsBaseline = await this.dependencies.git.fingerprintDeniedPaths(worktreePath, config.deny.readPaths);
1049
- let implementation = recoveredFeedbackImplementation ?? await this.runImplementation({
1050
- operation: 'implementation',
1291
+ const phaseFacts = [deniedPathsBaseline, sha256(canonicalJson(resolveIssueCheckPolicy(active.record.issueSnapshot.body, config.checks).checks)), canonicalJson(active.record.checkQualification?.checks ?? [])];
1292
+ const recoveredContext = active.record.mutableInvocation?.context;
1293
+ const invocationState = this.mutableInvocationState(() => active, (next) => { active = next; });
1294
+ let implementation = await this.runImplementation({
1295
+ operation: feedbackBatch ? 'review-feedback-implementation' : 'implementation',
1051
1296
  runId,
1052
1297
  worktreePath,
1053
1298
  issue: publicIssueSnapshot(issueSnapshot),
1054
1299
  frozenCriteria,
1055
1300
  cycle: active.record.cycle,
1056
- reworkFindings: active.record.reworkFindings,
1057
- repairOnly: false,
1301
+ reworkFindings: recoveredContext?.reworkFindings ?? active.record.reworkFindings,
1302
+ repairOnly: recoveredContext?.repairOnly ?? false,
1058
1303
  workflowGeneration: active.record.workflowGeneration,
1304
+ phaseFacts,
1305
+ invocationState,
1059
1306
  ...implementationLaunch,
1060
1307
  });
1061
- recoveredFeedbackImplementation = undefined;
1308
+ if (feedbackImplementationLaunchFailure)
1309
+ return feedbackImplementationLaunchFailure;
1062
1310
  if (implementation.kind === 'safe-halt') {
1063
- active = await this.persist(active, {
1064
- lifecycle: 'safe-halt',
1065
- process: {
1066
- ...implementation.process,
1067
- purpose: 'implementation',
1068
- resumeLifecycle: 'implementing',
1069
- resumeReviewStage: null,
1070
- },
1071
- });
1072
- while (true) {
1073
- try {
1074
- await implementation.waitForAbsence();
1075
- break;
1076
- }
1077
- catch {
1078
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1079
- }
1311
+ if (implementation.code === 'report-repair-modified-worktree') {
1312
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, implementation.code);
1080
1313
  }
1081
- if (await this.dependencies.git.fingerprintDeniedPaths(worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
1082
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'denied-path-modified');
1314
+ if (implementation.code === 'mutable-operation-result-worktree-mismatch') {
1315
+ active = await this.persist(active, { mutableInvocation: undefined });
1316
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, implementation.code);
1083
1317
  }
1084
- return await this.terminal(active, { status: 'transport-failed', resumable: false }, 'process-quiescence-delayed');
1318
+ return await this.invokedFailure(active, implementation.code, 'Mutable implementation recovery remains fenced and requires a later bounded observation.');
1085
1319
  }
1086
1320
  if (await this.dependencies.git.fingerprintDeniedPaths(worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
1321
+ active = await this.persist(active, { mutableInvocation: undefined });
1087
1322
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'denied-path-modified');
1088
1323
  }
1089
- if (implementation.kind === 'transport-failed' && implementation.resumable && active.record.transportRetries === 0) {
1090
- const invocation = active.record.reviewFeedback?.implementationInvocation;
1091
- if (invocation && !sameFreshness(await this.dependencies.git.snapshot(worktreePath), invocation.baseline)) {
1092
- return await this.blockReviewFeedback(active, 'safety', 'review-feedback-implementation-worktree-ambiguous');
1093
- }
1094
- active = await this.persist(active, { transportRetries: 1 });
1095
- continue attemptLoop;
1096
- }
1324
+ if (implementation.kind === 'transport-failed')
1325
+ return this.invokedFailure(active, implementation.code ?? 'implementation-agent-transport-failed', 'Mutable implementation did not produce recoverable output; the durable invocation remains available for recovery.');
1097
1326
  if (implementation.kind !== 'completed')
1098
1327
  return await this.mapImplementationFailure(active, implementation);
1099
1328
  let report;
@@ -1102,15 +1331,16 @@ export class RunIssue {
1102
1331
  }
1103
1332
  catch {
1104
1333
  if (active.record.reportRepairs >= 1) {
1334
+ active = await this.persist(active, { mutableInvocation: undefined });
1105
1335
  return await this.terminal(active, { status: 'internal-error', code: 'implementation-report-malformed' });
1106
1336
  }
1107
1337
  const repairBaseline = await this.dependencies.git.snapshot(worktreePath);
1108
- active = await this.persist(active, { reportRepairs: 1 });
1338
+ active = await this.persist(active, { reportRepairs: 1, mutableInvocation: undefined });
1109
1339
  const repairBlock = await this.revalidateFeedbackWorker(active, config, input.issueNumber);
1110
1340
  if (repairBlock)
1111
1341
  return repairBlock;
1112
1342
  implementation = await this.runImplementation({
1113
- operation: 'implementation',
1343
+ operation: feedbackBatch ? 'review-feedback-implementation' : 'implementation',
1114
1344
  runId,
1115
1345
  worktreePath,
1116
1346
  issue: publicIssueSnapshot(issueSnapshot),
@@ -1119,60 +1349,56 @@ export class RunIssue {
1119
1349
  reworkFindings: ['The previous implementation report did not match the generated schema.'],
1120
1350
  repairOnly: true,
1121
1351
  workflowGeneration: active.record.workflowGeneration,
1352
+ phaseFacts,
1353
+ invocationState,
1122
1354
  ...implementationLaunch,
1123
1355
  });
1356
+ if (feedbackImplementationLaunchFailure)
1357
+ return feedbackImplementationLaunchFailure;
1124
1358
  if (implementation.kind === 'safe-halt') {
1125
- active = await this.persist(active, {
1126
- lifecycle: 'safe-halt',
1127
- process: {
1128
- ...implementation.process,
1129
- purpose: 'implementation',
1130
- resumeLifecycle: 'implementing',
1131
- resumeReviewStage: null,
1132
- },
1133
- });
1134
- while (true) {
1135
- try {
1136
- await implementation.waitForAbsence();
1137
- break;
1138
- }
1139
- catch {
1140
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1141
- }
1359
+ if (implementation.code === 'report-repair-modified-worktree') {
1360
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, implementation.code);
1142
1361
  }
1143
- return await this.terminal(active, { status: 'transport-failed', resumable: false }, 'process-quiescence-delayed');
1362
+ return this.invokedFailure(active, implementation.code);
1144
1363
  }
1364
+ if (implementation.kind === 'transport-failed')
1365
+ return this.invokedFailure(active, implementation.code ?? 'implementation-agent-transport-failed');
1145
1366
  if (implementation.kind !== 'completed')
1146
1367
  return await this.mapImplementationFailure(active, implementation);
1147
1368
  const afterRepair = await this.dependencies.git.snapshot(worktreePath);
1148
1369
  if (!sameFreshness(repairBaseline, afterRepair)) {
1370
+ active = await this.persist(active, { mutableInvocation: undefined });
1149
1371
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'report-repair-modified-worktree');
1150
1372
  }
1151
1373
  try {
1152
1374
  report = validateImplementationReport(implementation.report);
1153
1375
  }
1154
1376
  catch {
1377
+ active = await this.persist(active, { mutableInvocation: undefined });
1155
1378
  return await this.terminal(active, { status: 'internal-error', code: 'implementation-report-malformed' });
1156
1379
  }
1157
1380
  }
1158
1381
  if (report.status === 'external-block') {
1382
+ active = await this.persist(active, { mutableInvocation: undefined });
1159
1383
  return await this.terminal(active, { status: 'blocked', kind: 'external', resumable: true });
1160
1384
  }
1161
1385
  if (await this.dependencies.git.getHead(worktreePath) !== expectedImplementationHead(active.record)) {
1386
+ active = await this.persist(active, { mutableInvocation: undefined });
1162
1387
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
1163
1388
  }
1164
1389
  const changedFiles = await this.dependencies.git.listChangedFiles(worktreePath);
1165
1390
  if (changedFiles.length === 0 || !sameStrings(changedFiles, report.changedFiles)) {
1166
1391
  if (changedFiles.length === 0 || active.record.reportRepairs >= 1) {
1392
+ active = await this.persist(active, { mutableInvocation: undefined });
1167
1393
  return await this.terminal(active, { status: 'internal-error', code: 'implementation-change-set-invalid' });
1168
1394
  }
1169
1395
  const repairBaseline = await this.dependencies.git.snapshot(worktreePath);
1170
- active = await this.persist(active, { reportRepairs: 1 });
1396
+ active = await this.persist(active, { reportRepairs: 1, mutableInvocation: undefined });
1171
1397
  const repairBlock = await this.revalidateFeedbackWorker(active, config, input.issueNumber);
1172
1398
  if (repairBlock)
1173
1399
  return repairBlock;
1174
1400
  implementation = await this.runImplementation({
1175
- operation: 'implementation',
1401
+ operation: feedbackBatch ? 'review-feedback-implementation' : 'implementation',
1176
1402
  runId,
1177
1403
  worktreePath,
1178
1404
  issue: publicIssueSnapshot(issueSnapshot),
@@ -1181,52 +1407,64 @@ export class RunIssue {
1181
1407
  reworkFindings: [`The report changedFiles must equal the complete current product change set: ${canonicalJson(changedFiles)}.`],
1182
1408
  repairOnly: true,
1183
1409
  workflowGeneration: active.record.workflowGeneration,
1410
+ phaseFacts,
1411
+ invocationState,
1184
1412
  ...implementationLaunch,
1185
1413
  });
1414
+ if (feedbackImplementationLaunchFailure)
1415
+ return feedbackImplementationLaunchFailure;
1186
1416
  if (implementation.kind === 'safe-halt') {
1187
- active = await this.persist(active, {
1188
- lifecycle: 'safe-halt',
1189
- process: {
1190
- ...implementation.process,
1191
- purpose: 'implementation',
1192
- resumeLifecycle: 'implementing',
1193
- resumeReviewStage: null,
1194
- },
1195
- });
1196
- while (true) {
1197
- try {
1198
- await implementation.waitForAbsence();
1199
- break;
1200
- }
1201
- catch {
1202
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1203
- }
1417
+ if (implementation.code === 'report-repair-modified-worktree') {
1418
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, implementation.code);
1204
1419
  }
1205
- return await this.terminal(active, { status: 'transport-failed', resumable: false }, 'process-quiescence-delayed');
1420
+ return this.invokedFailure(active, implementation.code);
1206
1421
  }
1422
+ if (implementation.kind === 'transport-failed')
1423
+ return this.invokedFailure(active, implementation.code ?? 'implementation-agent-transport-failed');
1207
1424
  if (implementation.kind !== 'completed')
1208
1425
  return await this.mapImplementationFailure(active, implementation);
1209
1426
  const afterRepair = await this.dependencies.git.snapshot(worktreePath);
1210
1427
  if (!sameFreshness(repairBaseline, afterRepair)) {
1428
+ active = await this.persist(active, { mutableInvocation: undefined });
1211
1429
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'report-repair-modified-worktree');
1212
1430
  }
1213
1431
  try {
1214
1432
  report = validateImplementationReport(implementation.report);
1215
1433
  }
1216
1434
  catch {
1435
+ active = await this.persist(active, { mutableInvocation: undefined });
1217
1436
  return await this.terminal(active, { status: 'internal-error', code: 'implementation-report-malformed' });
1218
1437
  }
1219
1438
  if (report.status === 'external-block') {
1439
+ active = await this.persist(active, { mutableInvocation: undefined });
1220
1440
  return await this.terminal(active, { status: 'blocked', kind: 'external', resumable: true });
1221
1441
  }
1222
1442
  if (!sameStrings(changedFiles, report.changedFiles)) {
1443
+ active = await this.persist(active, { mutableInvocation: undefined });
1223
1444
  return await this.terminal(active, { status: 'internal-error', code: 'implementation-change-set-invalid' });
1224
1445
  }
1225
1446
  }
1226
1447
  if (active.record.routeReceipt?.route === 'direct') {
1227
- const targetFingerprint = directReviewTargetFingerprint({
1228
- snapshot: await this.dependencies.git.snapshot(worktreePath),
1229
- changedFiles,
1448
+ const boundary = active.record.reviewFeedback?.activeBatch
1449
+ ? {
1450
+ kind: 'review-feedback',
1451
+ batchId: active.record.reviewFeedback.activeBatch.batchId,
1452
+ repairRound: active.record.reviewFeedback.repairRound,
1453
+ }
1454
+ : { kind: 'implementation-cycle', cycle: active.record.cycle };
1455
+ const captured = await this.ensureCandidateBinding(active, config, boundary);
1456
+ if ('status' in captured)
1457
+ return captured;
1458
+ active = captured.active;
1459
+ const binding = active.record.candidateBinding;
1460
+ if (!sameStrings(binding.canonicalChangedFiles, report.changedFiles)) {
1461
+ const released = await this.clearAndReleaseCandidate(active);
1462
+ if ('status' in released)
1463
+ return released;
1464
+ return this.terminal(released.active, { status: 'internal-error', code: 'implementation-change-set-invalid' });
1465
+ }
1466
+ const targetFingerprint = directReviewCandidateTargetFingerprint({
1467
+ binding,
1230
1468
  routeDecisionSha256: active.record.routeReceipt.decisionSha256,
1231
1469
  workflowGenerationHash: active.record.workflowGeneration.generationHash,
1232
1470
  cycle: active.record.cycle,
@@ -1235,9 +1473,7 @@ export class RunIssue {
1235
1473
  if (active.record.directReview?.stage === 'review-repair') {
1236
1474
  active = await this.persist(active, {
1237
1475
  directReview: prepareDirectReviewClosure(active.record.directReview, targetFingerprint).state,
1238
- ...(active.record.reviewFeedback ? {
1239
- reviewFeedback: { ...structuredClone(active.record.reviewFeedback), implementationInvocation: null },
1240
- } : {}),
1476
+ mutableInvocation: undefined,
1241
1477
  });
1242
1478
  }
1243
1479
  else {
@@ -1248,9 +1484,7 @@ export class RunIssue {
1248
1484
  targetFingerprint,
1249
1485
  codeReviewerSessionId: reviewerSessionId,
1250
1486
  }),
1251
- ...(active.record.reviewFeedback ? {
1252
- reviewFeedback: { ...structuredClone(active.record.reviewFeedback), implementationInvocation: null },
1253
- } : {}),
1487
+ mutableInvocation: undefined,
1254
1488
  });
1255
1489
  }
1256
1490
  const reviewed = await this.runDirectReviewFull(active, publicIssueSnapshot(issueSnapshot), frozenCriteria, implementation.attemptId ?? `implementation-cycle-${active.record.cycle}`, config.runner.maxCycles, config);
@@ -1263,21 +1497,32 @@ export class RunIssue {
1263
1497
  else {
1264
1498
  active = await this.persist(active, {
1265
1499
  lifecycle: 'checking',
1266
- ...(active.record.reviewFeedback ? {
1267
- reviewFeedback: { ...structuredClone(active.record.reviewFeedback), implementationInvocation: null },
1268
- } : {}),
1500
+ mutableInvocation: undefined,
1269
1501
  });
1270
1502
  }
1271
1503
  }
1272
- let checkPolicy;
1504
+ let currentCheckPolicy;
1273
1505
  try {
1274
- checkPolicy = resolveIssueCheckPolicy(active.record.issueSnapshot.body, config.checks);
1506
+ currentCheckPolicy = resolveIssueCheckPolicy(active.record.issueSnapshot.body, config.checks);
1275
1507
  }
1276
1508
  catch (error) {
1277
1509
  return await this.invokedFailure(active, 'issue-verification-invalid', error instanceof Error ? error.message : undefined);
1278
1510
  }
1279
- const configuredChecks = Object.entries(checkPolicy.checks);
1511
+ const currentCheckPolicySha256 = sha256(canonicalJson(currentCheckPolicy.checks));
1512
+ const recoveringPersistedProof = active.record.lifecycle === 'proving' && active.record.executionLease?.operation === 'acceptance-proof'
1513
+ && active.record.checkedChangeSha256 !== undefined && active.record.proofId !== undefined;
1514
+ const persistedProofCheckPolicySha256 = active.record.checks.find((check) => 'checkPolicySha256' in check)?.checkPolicySha256 ?? active.record.checkQualification?.checkPolicySha256;
1515
+ const configuredChecks = recoveringPersistedProof
1516
+ ? active.record.checks.map((check) => [check.id, check.command]) : Object.entries(currentCheckPolicy.checks);
1517
+ const finalCheckPolicySha256 = recoveringPersistedProof && persistedProofCheckPolicySha256 ? persistedProofCheckPolicySha256 : currentCheckPolicySha256;
1518
+ const finalBinding = active.record.candidateBinding;
1519
+ if (!finalBinding)
1520
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-git-v2-required');
1280
1521
  const reusableChecks = active.record.checks.filter((check) => check.status === 'passed'
1522
+ && 'bindingId' in check
1523
+ && check.bindingId === finalBinding.bindingId
1524
+ && check.candidateTreeSha === finalBinding.candidateTreeSha
1525
+ && check.checkPolicySha256 === finalCheckPolicySha256
1281
1526
  && configuredChecks.some(([id, command]) => check.id === id && check.command === command));
1282
1527
  if (reusableChecks.length !== active.record.checks.length) {
1283
1528
  active = await this.persist(active, {
@@ -1290,13 +1535,34 @@ export class RunIssue {
1290
1535
  for (const [id, command] of configuredChecks) {
1291
1536
  if (active.record.checks.some((check) => check.id === id
1292
1537
  && check.command === command
1293
- && check.status === 'passed'))
1538
+ && check.status === 'passed'
1539
+ && 'bindingId' in check
1540
+ && check.bindingId === finalBinding.bindingId
1541
+ && check.candidateTreeSha === finalBinding.candidateTreeSha
1542
+ && check.checkPolicySha256 === finalCheckPolicySha256))
1294
1543
  continue;
1295
1544
  if (this.signal.aborted)
1296
1545
  return await this.terminal(active, { status: 'cancelled' });
1546
+ if (this.dependencies.checks.supportsLaunchOwnership !== true) {
1547
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-check-launch-ownership-required');
1548
+ }
1549
+ const execution = await this.prepareCandidateExecution(active, config, 'final-check', `${id}:${finalCheckPolicySha256}`);
1550
+ if ('status' in execution)
1551
+ return execution;
1552
+ active = execution.active;
1553
+ const lease = execution.lease;
1297
1554
  let check;
1298
1555
  try {
1299
- check = await this.dependencies.checks.run({ id, command, source: checkPolicy.source, cwd: worktreePath, phase: 'changed', signal: this.signal });
1556
+ check = await this.dependencies.checks.run({
1557
+ id, command, source: currentCheckPolicy.source, cwd: lease.path, phase: 'changed', signal: this.signal,
1558
+ onLaunched: async ({ pid, processGroupId }) => {
1559
+ active = await this.persist(active, {
1560
+ executionLease: this.dependencies.git.candidateV2.markExecutionLaunched({
1561
+ lease, pid, processGroupId, launchedAt: this.timestamp(),
1562
+ }),
1563
+ });
1564
+ },
1565
+ });
1300
1566
  }
1301
1567
  catch (error) {
1302
1568
  if (error instanceof CheckProcessQuiescenceError) {
@@ -1306,160 +1572,162 @@ export class RunIssue {
1306
1572
  }
1307
1573
  if (this.signal.aborted)
1308
1574
  return await this.terminal(active, { status: 'cancelled' });
1309
- const row = { id, command, status: check.status, outputSha256: check.outputSha256 };
1575
+ const settledExecution = await this.settleCandidateExecution(active, config);
1576
+ if ('status' in settledExecution)
1577
+ return settledExecution;
1578
+ active = settledExecution.active;
1579
+ const row = {
1580
+ id, command, status: check.status, outputSha256: check.outputSha256,
1581
+ bindingId: finalBinding.bindingId,
1582
+ candidateTreeSha: finalBinding.candidateTreeSha,
1583
+ checkPolicySha256: finalCheckPolicySha256,
1584
+ };
1310
1585
  active = await this.persist(active, { checks: [...active.record.checks, row] });
1311
1586
  if (check.status === 'failed') {
1312
1587
  if (this.repairBudgetExhausted(active, config.runner.maxCycles)) {
1588
+ const released = await this.clearAndReleaseCandidate(active);
1589
+ if ('status' in released)
1590
+ return released;
1591
+ active = released.active;
1313
1592
  return active.record.reviewFeedback?.activeBatch
1314
1593
  ? await this.blockReviewFeedback(active, 'exhausted', 'review-feedback-check-repair-exhausted')
1315
1594
  : await this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true });
1316
1595
  }
1317
1596
  const summary = `Check ${id} failed:\n${check.output.toString('utf8').slice(0, 8 * 1024)}`;
1318
- active = await this.startNextCycle(active, [summary], [{
1597
+ const reopened = await this.startNextCycleFromCandidate(active, [summary], [{
1319
1598
  provenance: 'check', sourceId: `check:${id}:${row.outputSha256}`, summary,
1320
1599
  affectedContracts: ['configured-checks'],
1321
1600
  }]);
1601
+ if ('status' in reopened)
1602
+ return reopened;
1603
+ active = reopened;
1322
1604
  continue attemptLoop;
1323
1605
  }
1324
1606
  }
1325
- let reviewedStageBinding;
1326
- try {
1327
- const reviewedFiles = await this.dependencies.git.listChangedFiles(worktreePath);
1328
- const reviewedContentSha256 = await this.dependencies.git.fingerprintChangedFiles(worktreePath, reviewedFiles);
1329
- reviewedStageBinding = { files: reviewedFiles, contentSha256: reviewedContentSha256 };
1330
- if (active.record.directReview?.status === 'clear') {
1331
- const currentFingerprint = directReviewTargetFingerprint({
1332
- snapshot: await this.dependencies.git.snapshot(worktreePath),
1333
- changedFiles: await this.dependencies.git.listChangedFiles(worktreePath),
1334
- routeDecisionSha256: active.record.routeReceipt.decisionSha256,
1335
- workflowGenerationHash: active.record.workflowGeneration.generationHash,
1336
- cycle: active.record.cycle,
1337
- frozenCriteria,
1338
- });
1339
- if (currentFingerprint !== active.record.directReview.targetFingerprint) {
1340
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'direct-review-target-drift');
1341
- }
1342
- }
1343
- await this.dependencies.git.stageAll(worktreePath);
1344
- const stagedFiles = await this.dependencies.git.listChangedFiles(worktreePath);
1345
- const stagedContentSha256 = await this.dependencies.git.fingerprintChangedFiles(worktreePath, stagedFiles);
1346
- if (!sameStrings(reviewedFiles, stagedFiles) || reviewedContentSha256 !== stagedContentSha256) {
1347
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'post-stage-review-binding-drift');
1348
- }
1349
- }
1350
- catch {
1351
- return await this.terminal(active, { status: 'internal-error', code: 'local-git-effect-failed' });
1352
- }
1353
- if (await this.dependencies.git.getHead(worktreePath) !== expectedImplementationHead(active.record)) {
1354
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
1355
- }
1356
- const proofChangedFiles = await this.dependencies.git.listChangedFiles(worktreePath);
1357
- const freshness = await this.dependencies.git.snapshot(worktreePath);
1358
- const proofContentSha256 = await this.dependencies.git.fingerprintChangedFiles(worktreePath, proofChangedFiles);
1359
- if (!reviewedStageBinding || !sameStrings(reviewedStageBinding.files, proofChangedFiles)
1360
- || reviewedStageBinding.contentSha256 !== proofContentSha256) {
1361
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'checked-change-review-binding-drift');
1362
- }
1363
- if (!sameCheckPolicy(active.record.checks, checkPolicy.checks)) {
1607
+ if (!sameCheckPolicy(active.record.checks, recoveringPersistedProof ? Object.fromEntries(configuredChecks) : currentCheckPolicy.checks)) {
1364
1608
  return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'configured-check-policy-drift');
1365
1609
  }
1610
+ const proofBinding = active.record.candidateBinding;
1611
+ if (!proofBinding || active.record.checks.some((check) => !('bindingId' in check)
1612
+ || check.bindingId !== proofBinding.bindingId || check.candidateTreeSha !== proofBinding.candidateTreeSha
1613
+ || check.checkPolicySha256 !== finalCheckPolicySha256)) {
1614
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'checked-change-candidate-binding-drift');
1615
+ }
1366
1616
  const payload = {
1367
- version: 1,
1617
+ version: 2,
1368
1618
  canonicalRepository,
1369
1619
  runId,
1370
1620
  issueNumber: input.issueNumber,
1371
1621
  cycle: active.record.cycle,
1372
1622
  baseSha: expectedImplementationHead(active.record),
1373
- headSha: freshness.headSha,
1374
- indexTreeSha: freshness.indexTreeSha,
1375
- trackedContentSha256: freshness.trackedContentSha256,
1376
- untrackedContentSha256: freshness.untrackedContentSha256,
1377
- worktreeIdentity: freshness.worktreeIdentity,
1378
- changedFiles: proofChangedFiles,
1379
- checks: active.record.checks.map((check) => ({
1380
- ...check,
1381
- status: 'passed',
1382
- })),
1383
- checkPolicySha256: sha256(canonicalJson(checkPolicy.checks)),
1623
+ binding: structuredClone(proofBinding),
1624
+ changedFiles: [...proofBinding.canonicalChangedFiles],
1625
+ checks: active.record.checks.map((check) => ({ ...check, status: 'passed' })),
1626
+ checkPolicySha256: finalCheckPolicySha256,
1384
1627
  packageVersion: active.record.packageVersion,
1385
1628
  proofSchemaVersion: 1,
1386
1629
  };
1387
1630
  const checkedChange = this.dependencies.checkedChangeMint.mint(payload);
1388
1631
  const checkedChangeSha256 = checkedChangePayloadSha256(payload);
1389
- const proofId = this.dependencies.createProofId();
1632
+ if (active.record.checkedChangeSha256 && active.record.checkedChangeSha256 !== checkedChangeSha256) {
1633
+ return await this.persistCandidateEvidenceSafetyTerminal(active, 'checked-change-recovery-binding-drift');
1634
+ }
1635
+ const proofId = active.record.proofId ?? this.dependencies.createProofId();
1390
1636
  assertNonEmptyString(proofId, 'proofId');
1391
1637
  active = await this.persist(active, { lifecycle: 'proving', checkedChangeSha256, proofId });
1638
+ const retainedProofLease = active.record.executionLease?.operation === 'acceptance-proof'
1639
+ ? active.record.executionLease
1640
+ : undefined;
1641
+ const preparedProofExecution = retainedProofLease
1642
+ ? { active, lease: retainedProofLease }
1643
+ : await this.prepareCandidateExecution(active, config, 'acceptance-proof', proofId);
1644
+ if ('status' in preparedProofExecution)
1645
+ return preparedProofExecution;
1646
+ active = preparedProofExecution.active;
1647
+ const proofExecution = preparedProofExecution;
1392
1648
  let proof;
1393
1649
  try {
1650
+ if (active.record.proofReceipt) {
1651
+ const settled = await this.settleCandidateExecution(active, config);
1652
+ if ('status' in settled)
1653
+ return settled;
1654
+ active = await this.persist(settled.active, { lifecycle: 'publishing', reworkFindings: [] });
1655
+ break;
1656
+ }
1394
1657
  proof = await this.dependencies.proof.proveChange({
1395
1658
  proofId,
1396
1659
  issue: publicIssueSnapshot(issueSnapshot),
1397
1660
  frozenCriteria,
1398
1661
  checkedChange,
1662
+ executionLease: proofExecution.lease,
1399
1663
  workflowGeneration: structuredClone(active.record.workflowGeneration),
1400
1664
  beforeAgentLaunch: async () => {
1401
- const failure = await this.revalidateFeedbackWorker(active, config, input.issueNumber);
1402
- if (failure)
1403
- throw new ProofLaunchAuthorizationError(failure);
1665
+ if (recoveringPersistedProof && currentCheckPolicySha256 !== finalCheckPolicySha256)
1666
+ throw new ProofLaunchAuthorizationError({ status: 'blocked', code: 'configured-check-policy-drift' });
1667
+ const observation = !await this.authorized(active, config) ? { status: 'blocked', code: 'proof-worker-authority-revoked' } : await this.observeFeedbackWorker(active, config, input.issueNumber);
1668
+ if (observation.status !== 'valid')
1669
+ throw new ProofLaunchAuthorizationError(observation);
1404
1670
  },
1405
1671
  });
1406
1672
  }
1407
1673
  catch (error) {
1408
- if (error instanceof ProofLaunchAuthorizationError)
1409
- return error.outcome;
1410
- if (error instanceof ProofQuiescenceError) {
1411
- active = await this.persist(active, {
1412
- lifecycle: 'safe-halt',
1413
- process: {
1414
- pid: error.pid,
1415
- processGroupId: error.processGroupId,
1416
- startedAt: this.timestamp(),
1417
- baseline: {
1418
- headSha: freshness.headSha,
1419
- indexTreeSha: freshness.indexTreeSha,
1420
- trackedContentSha256: freshness.trackedContentSha256,
1421
- untrackedContentSha256: freshness.untrackedContentSha256,
1422
- worktreeIdentity: freshness.worktreeIdentity,
1423
- },
1424
- purpose: 'proof',
1425
- resumeLifecycle: 'proving',
1426
- resumeReviewStage: null,
1427
- },
1428
- });
1429
- while (true) {
1430
- try {
1431
- await error.waitForAbsence();
1432
- break;
1433
- }
1434
- catch {
1435
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1436
- }
1674
+ if (error instanceof ProofLaunchAuthorizationError) {
1675
+ const observation = error.outcome;
1676
+ if (observation.code === 'configured-check-policy-drift') {
1677
+ const settled = await this.settleCandidateExecution(active, config);
1678
+ return 'status' in settled ? settled : this.terminal(settled.active, { status: 'blocked', kind: 'safety', resumable: true }, observation.code);
1437
1679
  }
1438
- return await this.terminal(active, { status: 'transport-failed', resumable: false }, 'proof-process-quiescence-delayed');
1680
+ if (observation.status === 'retryable')
1681
+ return this.invokedFailure(active, `${observation.code}-retryable`);
1682
+ return active.record.reviewFeedback?.activeBatch ? this.blockReviewFeedback(active, 'safety', observation.code) : this.revoked(active);
1683
+ }
1684
+ if (error instanceof CandidateProofInspectionError) {
1685
+ return error.code === 'candidate-artifact-conflict'
1686
+ ? this.persistCandidateEvidenceSafetyTerminal(active, error.code)
1687
+ : this.mapCandidateFailure(active, error.code);
1439
1688
  }
1440
1689
  return await this.terminal(active, { status: 'internal-error', code: 'acceptance-proof-internal-failure' });
1441
1690
  }
1691
+ if (proof.status === 'transport-failed' && proof.resumable) {
1692
+ return this.invokedFailure(active, 'proof-invocation-deferred', 'Acceptance proof ownership or output requires bounded reconciliation on the next tick.');
1693
+ }
1694
+ if (recoveringPersistedProof && currentCheckPolicySha256 !== finalCheckPolicySha256) {
1695
+ const settled = await this.settleCandidateExecution(active, config);
1696
+ return 'status' in settled ? settled : this.terminal(settled.active, { status: 'blocked', kind: 'safety', resumable: true }, 'configured-check-policy-drift');
1697
+ }
1698
+ if (proof.status === 'passed')
1699
+ active = await this.persist(active, { proofReceipt: proof.receipt });
1700
+ const settledProofExecution = await this.settleCandidateExecution(active, config);
1701
+ if ('status' in settledProofExecution)
1702
+ return settledProofExecution;
1703
+ active = settledProofExecution.active;
1442
1704
  if (this.signal.aborted)
1443
1705
  return await this.terminal(active, { status: 'cancelled' });
1444
1706
  if (proof.status === 'needs-rework') {
1445
1707
  if (this.repairBudgetExhausted(active, config.runner.maxCycles)) {
1708
+ const released = await this.clearAndReleaseCandidate(active);
1709
+ if ('status' in released)
1710
+ return released;
1711
+ active = released.active;
1446
1712
  return active.record.reviewFeedback?.activeBatch
1447
1713
  ? await this.blockReviewFeedback(active, 'exhausted', 'review-feedback-proof-repair-exhausted')
1448
1714
  : await this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true });
1449
1715
  }
1450
- active = await this.startNextCycle(active, proof.findings, proof.findings.map((summary) => ({
1716
+ const reopened = await this.startNextCycleFromCandidate(active, proof.findings, proof.findings.map((summary) => ({
1451
1717
  provenance: 'proof',
1452
1718
  sourceId: `proof:${proofId}:${sha256(summary)}`,
1453
1719
  summary,
1454
1720
  affectedContracts: ['acceptance-proof'],
1455
1721
  })));
1722
+ if ('status' in reopened)
1723
+ return reopened;
1724
+ active = reopened;
1456
1725
  continue attemptLoop;
1457
1726
  }
1458
1727
  if (proof.status !== 'passed')
1459
1728
  return await this.mapProofFailure(active, proof);
1460
1729
  active = await this.persist(active, {
1461
1730
  lifecycle: 'publishing',
1462
- proofReceipt: proof.receipt,
1463
1731
  reworkFindings: [],
1464
1732
  ...(active.record.reviewFeedback?.activeBatch ? {
1465
1733
  reviewFeedback: markReviewFeedbackPublishing(markReviewFeedbackVerified(active.record.reviewFeedback, {
@@ -1525,7 +1793,6 @@ export class RunIssue {
1525
1793
  }
1526
1794
  async initializeClaimedRun(active, issue) {
1527
1795
  if (active.record.lifecycle !== 'claimed'
1528
- || active.record.process
1529
1796
  || active.record.intent
1530
1797
  || active.record.routeExecution
1531
1798
  || active.record.routeReceipt) {
@@ -1577,13 +1844,16 @@ export class RunIssue {
1577
1844
  active = await this.persist(active, { routeExecution: structuredClone(next) });
1578
1845
  return true;
1579
1846
  },
1580
- complete: async (expected, next, receipt) => {
1847
+ complete: async (expected, next, receipt, attemptId) => {
1581
1848
  if (!sameRouteExecution(active.record.routeExecution, expected))
1582
1849
  return false;
1850
+ if (active.record.reportInvocation?.attemptId !== attemptId)
1851
+ return false;
1583
1852
  active = await this.persist(active, {
1584
1853
  lifecycle: 'routed',
1585
1854
  routeExecution: structuredClone(next),
1586
1855
  routeReceipt: structuredClone(receipt),
1856
+ reportInvocation: undefined,
1587
1857
  });
1588
1858
  return true;
1589
1859
  },
@@ -1601,10 +1871,19 @@ export class RunIssue {
1601
1871
  routeReceipt: undefined,
1602
1872
  outcomeEvidenceId: evidence.id,
1603
1873
  terminalOutcome: { status: 'cancelled', evidencePath: evidence.path },
1874
+ reportInvocation: undefined,
1604
1875
  ...(active.record.waitingHuman ? { waitingHuman: terminalWaiting(active.record.waitingHuman, { status: 'cancelled' }) } : {}),
1605
1876
  });
1606
1877
  return true;
1607
1878
  },
1879
+ invocation: this.reportInvocationState(() => active, (next) => { active = next; }),
1880
+ settle: async (expected, next, attemptId) => {
1881
+ if (!sameRouteExecution(active.record.routeExecution, expected)
1882
+ || active.record.reportInvocation?.attemptId !== attemptId)
1883
+ return false;
1884
+ active = await this.persist(active, { routeExecution: structuredClone(next), reportInvocation: undefined });
1885
+ return true;
1886
+ },
1608
1887
  };
1609
1888
  const result = await this.dependencies.routeCoordinator.run({
1610
1889
  state,
@@ -1623,32 +1902,13 @@ export class RunIssue {
1623
1902
  ],
1624
1903
  signal: this.signal,
1625
1904
  });
1626
- if (result.status === 'repairable' || result.status === 'retryable')
1905
+ if (result.status === 'repairable')
1627
1906
  continue;
1907
+ if (result.status === 'retryable') {
1908
+ return { result: await this.invokedFailure(active, result.code, 'Report-only recovery is deferred to the next runner tick.') };
1909
+ }
1628
1910
  if (result.status === 'safe-halt') {
1629
- active = await this.persist(active, {
1630
- lifecycle: 'safe-halt',
1631
- process: {
1632
- ...result.process,
1633
- purpose: 'route',
1634
- resumeLifecycle: 'triaging',
1635
- resumeReviewStage: null,
1636
- },
1637
- });
1638
- while (true) {
1639
- try {
1640
- await result.waitForAbsence();
1641
- break;
1642
- }
1643
- catch {
1644
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
1645
- }
1646
- }
1647
- const after = await this.dependencies.git.snapshot(worktreePath);
1648
- if (!sameFreshness(result.process.baseline, after)) {
1649
- return { result: await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'report-operation-worktree-mutated') };
1650
- }
1651
- return { result: await this.terminal(active, { status: 'transport-failed', resumable: false }, 'process-quiescence-delayed') };
1911
+ return { result: await this.invokedFailure(active, result.code, 'Report-only process ownership remains unresolved; no relaunch was attempted.') };
1652
1912
  }
1653
1913
  if (result.status === 'cancelled') {
1654
1914
  if (!active.record.terminalOutcome)
@@ -1729,9 +1989,8 @@ export class RunIssue {
1729
1989
  status: 'blocked', kind: continuation.kind, resumable: continuation.kind !== 'exhausted',
1730
1990
  }, continuation.code) };
1731
1991
  }
1732
- if (continuation.status === 'retryable') {
1733
- return { result: await this.terminal(active, { status: 'transport-failed', resumable: true }, continuation.code) };
1734
- }
1992
+ if (continuation.status === 'retryable')
1993
+ return { result: await this.invokedFailure(active, continuation.code) };
1735
1994
  if (receipt.route !== 'direct') {
1736
1995
  const specContinuation = continuation;
1737
1996
  if (specContinuation.status !== 'completed')
@@ -1766,6 +2025,29 @@ export class RunIssue {
1766
2025
  };
1767
2026
  }
1768
2027
  specState(readActive, writeActive) {
2028
+ const invocation = (reserveSession) => ({
2029
+ read: async () => structuredClone(readActive().record.reportInvocation),
2030
+ compareAndSwap: async (expected, next) => {
2031
+ const active = readActive();
2032
+ const observed = active.record.reportInvocation;
2033
+ if (observed === undefined || expected === undefined ? observed !== expected
2034
+ : canonicalJson(observed) !== canonicalJson(expected))
2035
+ return false;
2036
+ const preparing = expected === undefined && next?.phase === 'prepared';
2037
+ const specDelivery = preparing ? reserveSession(active.record.specDelivery) : active.record.specDelivery;
2038
+ writeActive(await this.persist(active, { ...(specDelivery ? { specDelivery } : {}),
2039
+ reportInvocation: next ? structuredClone(next) : undefined }));
2040
+ return true;
2041
+ },
2042
+ });
2043
+ const settle = async (expected, next, attemptId) => {
2044
+ const active = readActive();
2045
+ if (canonicalJson(active.record.specDelivery) !== canonicalJson(expected)
2046
+ || active.record.reportInvocation?.attemptId !== attemptId)
2047
+ return false;
2048
+ writeActive(await this.persist(active, { specDelivery: structuredClone(next), reportInvocation: undefined }));
2049
+ return true;
2050
+ };
1769
2051
  return {
1770
2052
  read: async () => structuredClone(readActive().record.specDelivery),
1771
2053
  compareAndSwap: async (expected, next) => {
@@ -1781,6 +2063,34 @@ export class RunIssue {
1781
2063
  writeActive(saved);
1782
2064
  return true;
1783
2065
  },
2066
+ authorInvocation: (authorSessionId) => invocation((state) => reserveSpecAuthorSession(state, authorSessionId)),
2067
+ reviewInvocation: (reviewerSessionId) => invocation((state) => reserveSpecReviewerSession(state, reviewerSessionId)),
2068
+ settleAuthor: settle,
2069
+ settleReview: settle,
2070
+ };
2071
+ }
2072
+ reportInvocationState(readActive, writeActive, launch) {
2073
+ return {
2074
+ read: async () => structuredClone(readActive().record.reportInvocation),
2075
+ compareAndSwap: async (expected, next) => {
2076
+ const active = readActive();
2077
+ const observed = active.record.reportInvocation;
2078
+ if (observed === undefined || expected === undefined) {
2079
+ if (observed !== expected)
2080
+ return false;
2081
+ }
2082
+ else if (canonicalJson(observed) !== canonicalJson(expected))
2083
+ return false;
2084
+ const launching = expected?.phase === 'prepared' && next?.phase === 'launched';
2085
+ if (launching)
2086
+ await launch?.beforeLaunch();
2087
+ const launchChanges = launching && launch ? launch.launchChanges(next) : {};
2088
+ writeActive(await this.persist(active, {
2089
+ ...launchChanges,
2090
+ reportInvocation: next ? structuredClone(next) : undefined,
2091
+ }));
2092
+ return true;
2093
+ },
1784
2094
  };
1785
2095
  }
1786
2096
  async continueSpecRequired(active) {
@@ -1801,7 +2111,7 @@ export class RunIssue {
1801
2111
  if (result.status === 'cancelled')
1802
2112
  return await this.terminal(current, { status: 'cancelled' });
1803
2113
  if (result.status === 'retryable')
1804
- return await this.terminal(current, { status: 'transport-failed', resumable: true }, result.code);
2114
+ return await this.invokedFailure(current, result.code);
1805
2115
  return await this.terminal(current, { status: 'blocked', kind: result.kind, resumable: result.kind !== 'exhausted' }, result.code);
1806
2116
  }
1807
2117
  async continueWaitingHuman(active) {
@@ -1889,7 +2199,7 @@ export class RunIssue {
1889
2199
  const { config, ...persistedInput } = input;
1890
2200
  const record = {
1891
2201
  ...persistedInput,
1892
- lifecycle: 'claimed', cycle: 1, reportRepairs: 0, transportRetries: 0,
2202
+ lifecycle: 'claimed', cycle: 1, reportRepairs: 0,
1893
2203
  reworkFindings: [],
1894
2204
  packageVersion: workflow.receipt.packageVersion,
1895
2205
  workflowGeneration: structuredClone(workflow.receipt),
@@ -1897,23 +2207,21 @@ export class RunIssue {
1897
2207
  checks: [], createdAt: now, updatedAt: now,
1898
2208
  };
1899
2209
  const saved = await this.dependencies.runRecords.compareAndSwap(state.generation, {
1900
- schema: 'codex-orchestrator.agent-auto-state', version: 2, runs: [...state.runs, record],
2210
+ schema: 'codex-orchestrator.agent-auto-state', version: state.version, runs: [...state.runs, record],
1901
2211
  });
1902
2212
  return { state: saved, record: findRun(saved, input.runId), config };
1903
2213
  }
1904
- async persist(active, changes) {
2214
+ async persist(active, changes, stateVersion = active.state.version) {
1905
2215
  const record = { ...active.record, ...changes, updatedAt: this.timestamp() };
1906
2216
  if (Object.hasOwn(changes, 'intent') && changes.intent === undefined)
1907
2217
  delete record.intent;
1908
- if (Object.hasOwn(changes, 'process') && changes.process === undefined)
1909
- delete record.process;
1910
- for (const key of ['checkedChangeSha256', 'proofId', 'proofReceipt', 'terminalOutcome', 'outcomeEvidenceId', 'routeExecution', 'routeReceipt', 'reviewFeedback', 'checkQualification', 'baselineChecks']) {
2218
+ for (const key of ['checkedChangeSha256', 'proofId', 'proofReceipt', 'terminalOutcome', 'outcomeEvidenceId', 'routeExecution', 'routeReceipt', 'reportInvocation', 'mutableInvocation', 'reviewFeedback', 'checkQualification', 'baselineChecks', 'changeBindingVersion', 'candidateBinding', 'executionLease']) {
1911
2219
  if (Object.hasOwn(changes, key) && changes[key] === undefined)
1912
2220
  delete record[key];
1913
2221
  }
1914
2222
  const runs = active.state.runs.map((candidate) => candidate.runId === record.runId ? record : candidate);
1915
2223
  const saved = await this.dependencies.runRecords.compareAndSwap(active.state.generation, {
1916
- schema: 'codex-orchestrator.agent-auto-state', version: 2, runs,
2224
+ schema: 'codex-orchestrator.agent-auto-state', version: stateVersion, runs,
1917
2225
  });
1918
2226
  return { state: saved, record: findRun(saved, record.runId), config: active.config };
1919
2227
  }
@@ -1943,11 +2251,6 @@ export class RunIssue {
1943
2251
  return false;
1944
2252
  return this.hasTrustedClaim(issue, record);
1945
2253
  }
1946
- async authorizedForExactLabels(active, expectedLabels) {
1947
- const issue = await this.readIssue(active.record.issueNumber);
1948
- return !!issue && issue.state === 'OPEN' && sameStrings(issue.labels, expectedLabels)
1949
- && this.hasTrustedClaim(issue, active.record);
1950
- }
1951
2254
  hasTrustedClaim(issue, record) {
1952
2255
  const exactBody = claimComment(record.runId, record.issueNumber, record.branchName);
1953
2256
  const markers = issue.comments.filter(isClaimMarkerComment);
@@ -1982,21 +2285,31 @@ export class RunIssue {
1982
2285
  }
1983
2286
  }
1984
2287
  async revalidateFeedbackWorker(active, config, issueNumber) {
2288
+ const validation = await this.observeFeedbackWorker(active, config, issueNumber);
2289
+ if (validation.status === 'valid')
2290
+ return undefined;
2291
+ if (validation.status === 'retryable')
2292
+ return this.invokedFailure(active, `${validation.code}-retryable`);
2293
+ return this.blockReviewFeedback(active, 'safety', validation.code);
2294
+ }
2295
+ async observeFeedbackWorker(active, config, issueNumber) {
1985
2296
  const batch = active.record.reviewFeedback?.activeBatch;
1986
2297
  if (!batch)
1987
- return undefined;
2298
+ return { status: 'valid' };
1988
2299
  if (!await this.authorized(active, config)) {
1989
- return this.blockReviewFeedback(active, 'safety', 'review-feedback-worker-authority-revoked');
2300
+ return { status: 'blocked', code: 'review-feedback-worker-authority-revoked' };
1990
2301
  }
1991
2302
  if (!this.dependencies.reviewFeedback) {
1992
- return this.blockReviewFeedback(active, 'safety', 'review-feedback-worker-revalidation-unavailable');
2303
+ return { status: 'blocked', code: 'review-feedback-worker-revalidation-unavailable' };
1993
2304
  }
1994
2305
  const validation = await this.dependencies.reviewFeedback.revalidate({
1995
2306
  batch,
1996
2307
  epoch: 'pre-update',
1997
2308
  expectedHeadSha: batch.priorPublishedHeadSha,
1998
2309
  });
1999
- return this.mapFeedbackRevalidation(active, validation, 'review-feedback-worker-revalidation-failed');
2310
+ return validation.status === 'valid'
2311
+ ? { status: 'valid' }
2312
+ : { status: validation.status, code: 'review-feedback-worker-revalidation-failed' };
2000
2313
  }
2001
2314
  async mapFeedbackRevalidation(active, validation, safetyCode) {
2002
2315
  if (validation.status === 'valid')
@@ -2005,6 +2318,277 @@ export class RunIssue {
2005
2318
  return this.invokedFailure(active, `${safetyCode}-retryable`);
2006
2319
  return this.blockReviewFeedback(active, 'safety', safetyCode);
2007
2320
  }
2321
+ async ensureCandidateBinding(active, config, boundary) {
2322
+ const candidate = this.dependencies.git.candidateV2;
2323
+ if (!candidate)
2324
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-git-v2-required');
2325
+ if (active.record.candidateBinding) {
2326
+ const inspection = await candidate.inspectPin(active.record.candidateBinding);
2327
+ if (inspection.kind === 'failed')
2328
+ return this.mapCandidateFailure(active, inspection.code);
2329
+ if (inspection.value === 'missing')
2330
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-pin-missing');
2331
+ if (inspection.value === 'diverged')
2332
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-pin-diverged');
2333
+ return { active };
2334
+ }
2335
+ const captured = await candidate.captureAndPin({
2336
+ worktreePath: active.record.worktreePath,
2337
+ expectedHeadSha: expectedImplementationHead(active.record),
2338
+ runId: active.record.runId,
2339
+ boundary,
2340
+ artifactDir: config.proof.artifactDir,
2341
+ });
2342
+ if (captured.kind === 'failed')
2343
+ return this.mapCandidateFailure(active, captured.code);
2344
+ return { active: await this.persist(active, {
2345
+ changeBindingVersion: 2,
2346
+ candidateBinding: captured.value,
2347
+ }, 3) };
2348
+ }
2349
+ async cutoverLegacyImmutableBoundary(active, config, frozenCriteria) {
2350
+ const captured = await this.ensureCandidateBinding(active, config, {
2351
+ kind: 'implementation-cycle', cycle: active.record.cycle,
2352
+ });
2353
+ if ('status' in captured)
2354
+ return captured;
2355
+ active = captured.active;
2356
+ const binding = active.record.candidateBinding;
2357
+ const targetFingerprint = directReviewCandidateTargetFingerprint({
2358
+ binding,
2359
+ routeDecisionSha256: active.record.routeReceipt.decisionSha256,
2360
+ workflowGenerationHash: active.record.workflowGeneration.generationHash,
2361
+ cycle: active.record.cycle,
2362
+ frozenCriteria,
2363
+ });
2364
+ return { active: await this.persist(active, {
2365
+ lifecycle: 'implementing',
2366
+ directReview: createInitialDirectReview({
2367
+ targetFingerprint,
2368
+ codeReviewerSessionId: this.dependencies.createReviewSessionId(),
2369
+ }),
2370
+ checks: [], checkedChangeSha256: undefined, proofId: undefined, proofReceipt: undefined,
2371
+ checkQualification: undefined, baselineChecks: undefined,
2372
+ terminalOutcome: undefined, outcomeEvidenceId: undefined,
2373
+ }) };
2374
+ }
2375
+ async prepareCandidateExecution(active, config, operation, operationSourceId) {
2376
+ const candidate = this.dependencies.git.candidateV2;
2377
+ const binding = active.record.candidateBinding;
2378
+ if (!candidate || !binding)
2379
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-git-v2-required');
2380
+ const attemptId = sha256(canonicalJson({ bindingId: binding.bindingId, operation, operationSourceId }));
2381
+ const prepared = await candidate.prepareExecution({
2382
+ binding,
2383
+ runId: active.record.runId,
2384
+ workspaceRoot: resolve(dirname(active.record.worktreePath)),
2385
+ operation,
2386
+ attemptId,
2387
+ });
2388
+ if (prepared.kind === 'failed')
2389
+ return this.mapCandidateFailure(active, prepared.code);
2390
+ if (prepared.value.kind === 'path-diverged') {
2391
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'path-diverged');
2392
+ }
2393
+ const next = await this.persist(active, { executionLease: prepared.value.lease });
2394
+ return { active: next, lease: prepared.value.lease };
2395
+ }
2396
+ async settleCandidateExecution(active, config) {
2397
+ const candidate = this.dependencies.git.candidateV2;
2398
+ const binding = active.record.candidateBinding;
2399
+ const lease = active.record.executionLease;
2400
+ if (!candidate || !binding || !lease)
2401
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-execution-state-missing');
2402
+ const inspection = await candidate.inspectExecution({ binding, lease, artifactDir: config.proof.artifactDir });
2403
+ if (inspection.kind === 'failed')
2404
+ return this.mapCandidateFailure(active, inspection.code);
2405
+ if (inspection.value !== 'matching' && inspection.value !== 'missing') {
2406
+ return this.reconcilePersistedCandidateExecution(active, config);
2407
+ }
2408
+ if (inspection.value === 'matching') {
2409
+ const removed = await candidate.removeExecution({ lease, requireProcessAbsent: true });
2410
+ if (removed.kind === 'failed')
2411
+ return this.mapCandidateFailure(active, removed.code);
2412
+ }
2413
+ return { active: await this.persist(active, { executionLease: undefined }) };
2414
+ }
2415
+ async inspectCompletedReviewExecution(active, config) {
2416
+ const candidate = this.dependencies.git.candidateV2;
2417
+ const binding = active.record.candidateBinding;
2418
+ const lease = active.record.executionLease;
2419
+ if (!candidate || !binding || !lease || lease.operation !== 'direct-review') {
2420
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-state-missing');
2421
+ }
2422
+ const inspection = await candidate.inspectExecution({ binding, lease, artifactDir: config.proof.artifactDir });
2423
+ if (inspection.kind === 'failed')
2424
+ return this.mapCandidateFailure(active, inspection.code);
2425
+ if (inspection.value !== 'matching')
2426
+ return this.persistCandidateEvidenceSafetyTerminal(active, `candidate-execution-${inspection.value}`);
2427
+ return { lease };
2428
+ }
2429
+ async removeCompletedReviewExecution(active, lease) {
2430
+ const candidate = this.dependencies.git.candidateV2;
2431
+ if (!candidate)
2432
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-state-missing');
2433
+ const removed = await candidate.removeExecution({ lease, requireProcessAbsent: true });
2434
+ if (removed.kind === 'failed')
2435
+ return this.mapCandidateFailure(active, removed.code);
2436
+ return { active };
2437
+ }
2438
+ async reconcilePersistedCandidateExecution(active, config) {
2439
+ const candidate = this.dependencies.git.candidateV2;
2440
+ const binding = active.record.candidateBinding;
2441
+ const lease = active.record.executionLease;
2442
+ if (!candidate || !binding || !lease) {
2443
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-state-missing');
2444
+ }
2445
+ if (lease.operation === 'direct-review' && lease.phase === 'launched') {
2446
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'direct-review-canonical-invocation-missing');
2447
+ }
2448
+ if (lease.phase === 'launched') {
2449
+ try {
2450
+ await this.dependencies.waitForReviewProcessAbsence(lease.processGroupId);
2451
+ }
2452
+ catch {
2453
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-process-absence-unconfirmed');
2454
+ }
2455
+ }
2456
+ const inspection = await candidate.inspectExecution({ binding, lease, artifactDir: config.proof.artifactDir });
2457
+ if (inspection.kind === 'failed')
2458
+ return this.mapCandidateFailure(active, inspection.code);
2459
+ if (inspection.value === 'missing')
2460
+ return this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-missing');
2461
+ if (inspection.value === 'mutated') {
2462
+ const cleared = await this.persist(active, {
2463
+ executionLease: undefined, changeBindingVersion: undefined, candidateBinding: undefined,
2464
+ });
2465
+ const removed = await candidate.removeExecution({ lease, requireProcessAbsent: true });
2466
+ if (removed.kind === 'failed')
2467
+ return this.mapCandidateFailure(cleared, removed.code);
2468
+ const released = await candidate.releasePin({ binding, expectedPinnedCommitSha: binding.candidateCommitSha });
2469
+ if (released.kind === 'failed')
2470
+ return this.mapCandidateFailure(cleared, released.code);
2471
+ return this.persistTerminal(cleared, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-materialization-mutated', false);
2472
+ }
2473
+ if (lease.operation === 'acceptance-proof') {
2474
+ return { active };
2475
+ }
2476
+ const cleared = await this.persist(active, { executionLease: undefined });
2477
+ const removed = await candidate.removeExecution({ lease, requireProcessAbsent: true });
2478
+ if (removed.kind === 'failed')
2479
+ return this.mapCandidateFailure(cleared, removed.code);
2480
+ return { active: cleared };
2481
+ }
2482
+ async clearAndReleaseCandidate(active) {
2483
+ const candidate = this.dependencies.git.candidateV2;
2484
+ const binding = active.record.candidateBinding;
2485
+ if (!candidate || !binding)
2486
+ return { active };
2487
+ if (active.record.executionLease)
2488
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-execution-not-quiescent');
2489
+ const normalized = await candidate.normalizeSharedIndex({
2490
+ worktreePath: active.record.worktreePath,
2491
+ expectedHeadSha: expectedImplementationHead(active.record),
2492
+ });
2493
+ if (normalized.kind === 'failed')
2494
+ return this.mapCandidateFailure(active, normalized.code);
2495
+ const cleared = await this.persist(active, { changeBindingVersion: undefined, candidateBinding: undefined });
2496
+ const released = await candidate.releasePin({ binding, expectedPinnedCommitSha: binding.candidateCommitSha });
2497
+ if (released.kind === 'failed') {
2498
+ return this.invokedFailure(cleared, 'candidate-pin-release-pending', 'The candidate state transition is durable; orphan reconciliation will retry exact pin cleanup.');
2499
+ }
2500
+ return { active: cleared };
2501
+ }
2502
+ async persistRetainedCommitIntentTerminal(active, evidenceCode) {
2503
+ const intent = active.record.intent;
2504
+ if (!active.record.candidateBinding || (intent?.kind !== 'commit' && intent?.kind !== 'review-update-commit')) {
2505
+ return this.invokedFailure(active, 'retained-candidate-intent-state-invalid');
2506
+ }
2507
+ const evidence = await this.dependencies.writeEvidence({
2508
+ runId: active.record.runId,
2509
+ code: evidenceCode,
2510
+ summary: 'Candidate publication stopped locally with the exact commit intent and pin retained.',
2511
+ });
2512
+ const terminalOutcome = {
2513
+ status: 'blocked', kind: 'safety', resumable: false, evidencePath: evidence.path,
2514
+ };
2515
+ const directReview = active.record.directReview && active.record.directReview.status !== 'terminal'
2516
+ ? projectTerminalDirectReview(active.record.directReview, { status: 'blocked', kind: 'safety' })
2517
+ : active.record.directReview;
2518
+ await this.persist(active, {
2519
+ lifecycle: 'blocked',
2520
+ terminalOutcome,
2521
+ outcomeEvidenceId: evidence.id,
2522
+ ...(directReview ? { directReview } : {}),
2523
+ ...(active.record.reviewFeedback?.activeBatch ? {
2524
+ reviewFeedback: blockReviewFeedback(active.record.reviewFeedback, 'safety', this.timestamp()),
2525
+ } : {}),
2526
+ reportInvocation: undefined,
2527
+ });
2528
+ return publicOutcome(terminalOutcome);
2529
+ }
2530
+ async reconcileUnknownCandidatePublication(active) {
2531
+ const candidate = this.dependencies.git.candidateV2;
2532
+ const binding = active.record.candidateBinding;
2533
+ const intent = active.record.intent;
2534
+ if (!candidate || !binding || (intent?.kind !== 'commit' && intent?.kind !== 'review-update-commit')) {
2535
+ return publicOutcome(active.record.terminalOutcome);
2536
+ }
2537
+ const observation = await candidate.createOrObserveCommit({
2538
+ worktreePath: active.record.worktreePath,
2539
+ branchName: active.record.branchName,
2540
+ parentSha: intent.parentSha,
2541
+ treeSha: intent.treeSha,
2542
+ message: intent.message,
2543
+ candidateRef: intent.candidateRef,
2544
+ observeOnly: true,
2545
+ });
2546
+ if (observation.kind === 'failed') {
2547
+ return publicOutcome(active.record.terminalOutcome);
2548
+ }
2549
+ if (observation.value.kind === 'branch-diverged') {
2550
+ return this.persistRetainedCommitIntentTerminal(active, 'candidate-branch-diverged');
2551
+ }
2552
+ let directReview = active.record.directReview;
2553
+ if (directReview?.status === 'terminal') {
2554
+ const { terminalCode: _terminalCode, terminalOutcome: _terminalOutcome, ...preserved } = structuredClone(directReview);
2555
+ directReview = { ...preserved, status: 'clear' };
2556
+ }
2557
+ return { active: await this.persist(active, {
2558
+ lifecycle: 'publishing', terminalOutcome: undefined, outcomeEvidenceId: undefined,
2559
+ ...(directReview ? { directReview } : {}),
2560
+ }) };
2561
+ }
2562
+ async persistCandidateEvidenceSafetyTerminal(active, evidenceCode) {
2563
+ if (!active.record.candidateBinding)
2564
+ return this.invokedFailure(active, 'candidate-evidence-state-invalid');
2565
+ const evidence = await this.dependencies.writeEvidence({
2566
+ runId: active.record.runId,
2567
+ code: evidenceCode,
2568
+ summary: 'Candidate evidence was retained without a Git or GitHub effect.',
2569
+ });
2570
+ const terminalOutcome = {
2571
+ status: 'blocked', kind: 'safety', resumable: false, evidencePath: evidence.path,
2572
+ };
2573
+ const directReview = active.record.directReview && active.record.directReview.status !== 'terminal'
2574
+ ? projectTerminalDirectReview(active.record.directReview, { status: 'blocked', kind: 'safety' })
2575
+ : active.record.directReview;
2576
+ await this.persist(active, {
2577
+ lifecycle: 'blocked', terminalOutcome, outcomeEvidenceId: evidence.id,
2578
+ ...(directReview ? { directReview } : {}),
2579
+ ...(active.record.reviewFeedback?.activeBatch ? {
2580
+ reviewFeedback: blockReviewFeedback(active.record.reviewFeedback, 'safety', this.timestamp()),
2581
+ } : {}),
2582
+ reportInvocation: undefined,
2583
+ });
2584
+ return publicOutcome(terminalOutcome);
2585
+ }
2586
+ mapCandidateFailure(active, code) {
2587
+ if (code === 'candidate-unstable' || code === 'candidate-io-failed' || code === 'candidate-materialization-io-failed') {
2588
+ return this.invokedFailure(active, code, 'Candidate operation failed before an effect and may be retried without consuming a repair budget.');
2589
+ }
2590
+ return this.terminal(active, { status: 'transport-failed', resumable: false }, code);
2591
+ }
2008
2592
  async runImplementation(input) {
2009
2593
  try {
2010
2594
  return await this.dependencies.implementationAgent.run({
@@ -2017,6 +2601,23 @@ export class RunIssue {
2017
2601
  return { kind: 'internal-error' };
2018
2602
  }
2019
2603
  }
2604
+ mutableInvocationState(current, replace) {
2605
+ return {
2606
+ read: async () => structuredClone(current().record.mutableInvocation),
2607
+ compareAndSwap: async (expected, next) => {
2608
+ const active = current();
2609
+ if (canonicalJson(active.record.mutableInvocation ?? null) !== canonicalJson(expected ?? null))
2610
+ return false;
2611
+ try {
2612
+ replace(await this.persist(active, { mutableInvocation: next }));
2613
+ return true;
2614
+ }
2615
+ catch {
2616
+ return false;
2617
+ }
2618
+ },
2619
+ };
2620
+ }
2020
2621
  async runDirectReviewFull(starting, issue, frozenCriteria, implementationAttemptId, maxCycles, config) {
2021
2622
  let active = starting;
2022
2623
  let reportRepair;
@@ -2034,12 +2635,52 @@ export class RunIssue {
2034
2635
  ? directReviewClosureRequestSha256(directReview)
2035
2636
  : null;
2036
2637
  const mode = directReview.stage === 'review-closure' ? 'closure' : 'full';
2037
- const reviewerAuthorizationFailure = await this.revalidateFeedbackWorker(active, config, active.record.issueNumber);
2038
- if (reviewerAuthorizationFailure)
2039
- return reviewerAuthorizationFailure;
2638
+ const reviewerAuthorization = await this.observeFeedbackWorker(active, config, active.record.issueNumber);
2639
+ if (reviewerAuthorization.status !== 'valid') {
2640
+ const released = await this.clearAndReleaseCandidate(active);
2641
+ if ('status' in released)
2642
+ return released;
2643
+ active = released.active;
2644
+ return reviewerAuthorization.status === 'retryable'
2645
+ ? this.invokedFailure(active, `${reviewerAuthorization.code}-retryable`)
2646
+ : this.blockReviewFeedback(active, 'safety', reviewerAuthorization.code);
2647
+ }
2648
+ let executionLease;
2649
+ if (active.record.reportInvocation?.operation === 'code-review') {
2650
+ if (active.record.executionLease?.operation !== 'direct-review') {
2651
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'direct-review-candidate-execution-missing');
2652
+ }
2653
+ executionLease = active.record.executionLease;
2654
+ }
2655
+ else {
2656
+ const execution = await this.prepareCandidateExecution(active, config, 'direct-review', `${reviewerSessionId}:${directReview.targetRevision}:${mode}:${implementationAttemptId}`);
2657
+ if ('status' in execution)
2658
+ return execution;
2659
+ active = execution.active;
2660
+ executionLease = execution.lease;
2661
+ }
2662
+ const abortPreparedReview = async (validation) => {
2663
+ const settledExecution = await this.settleCandidateExecution(active, config);
2664
+ if ('status' in settledExecution)
2665
+ return settledExecution;
2666
+ active = settledExecution.active;
2667
+ if (active.record.reportInvocation)
2668
+ return this.invokedFailure(active, 'direct-review-authorization-changed-after-prepare');
2669
+ const released = await this.clearAndReleaseCandidate(active);
2670
+ if ('status' in released)
2671
+ return released;
2672
+ active = released.active;
2673
+ return validation.status === 'retryable'
2674
+ ? this.invokedFailure(active, `${validation.code}-retryable`)
2675
+ : this.blockReviewFeedback(active, 'safety', validation.code);
2676
+ };
2677
+ const launchAuthorization = await this.observeFeedbackWorker(active, config, active.record.issueNumber);
2678
+ if (launchAuthorization.status !== 'valid')
2679
+ return abortPreparedReview(launchAuthorization);
2680
+ let launchAuthorizationFailure;
2040
2681
  const result = await this.dependencies.implementationReviewer.run({
2041
2682
  runId: active.record.runId,
2042
- worktreePath: active.record.worktreePath,
2683
+ worktreePath: executionLease.path,
2043
2684
  operation: 'code-review',
2044
2685
  mode,
2045
2686
  reviewerSessionId,
@@ -2061,33 +2702,48 @@ export class RunIssue {
2061
2702
  validationDiagnostic: reportRepair?.diagnostic ?? null,
2062
2703
  originalReportBytes: reportRepair?.originalReportBytes ?? null,
2063
2704
  signal: this.signal,
2064
- onPrepared: async (invocation) => {
2065
- const current = active.record.directReview;
2066
- if (!current)
2067
- throw new Error('direct review disappeared before prepare');
2068
- active = await this.persist(active, {
2069
- directReview: prepareDirectReviewInvocation(current, invocation),
2070
- });
2071
- },
2072
- onLaunched: async (invocation) => {
2073
- const current = active.record.directReview;
2074
- if (!current)
2075
- throw new Error('direct review disappeared before launch');
2076
- active = await this.persist(active, {
2077
- directReview: launchDirectReviewInvocation(current, invocation),
2078
- });
2079
- },
2705
+ invocationState: this.reportInvocationState(() => active, (next) => { active = next; }, {
2706
+ beforeLaunch: async () => {
2707
+ const authorization = await this.observeFeedbackWorker(active, config, active.record.issueNumber);
2708
+ if (authorization.status !== 'valid') {
2709
+ launchAuthorizationFailure = authorization;
2710
+ throw new Error('direct review launch authorization changed');
2711
+ }
2712
+ },
2713
+ launchChanges: (invocation) => {
2714
+ const lease = active.record.executionLease;
2715
+ if (!lease || invocation.pid === null || invocation.processGroupId === null || invocation.launchedAt === null) {
2716
+ throw new Error('direct review candidate execution is missing at launch');
2717
+ }
2718
+ return {
2719
+ executionLease: this.dependencies.git.candidateV2.markExecutionLaunched({
2720
+ lease, pid: invocation.pid, processGroupId: invocation.processGroupId, launchedAt: invocation.launchedAt,
2721
+ }),
2722
+ };
2723
+ },
2724
+ }),
2080
2725
  });
2726
+ if (launchAuthorizationFailure)
2727
+ return abortPreparedReview(launchAuthorizationFailure);
2728
+ let adoptedExecution;
2729
+ if (result.kind === 'completed' || result.kind === 'report-invalid') {
2730
+ const inspected = await this.inspectCompletedReviewExecution(active, config);
2731
+ if ('status' in inspected)
2732
+ return inspected;
2733
+ adoptedExecution = inspected.lease;
2734
+ }
2735
+ else if (result.kind !== 'safe-halt') {
2736
+ const settledExecution = await this.settleCandidateExecution(active, config);
2737
+ if ('status' in settledExecution)
2738
+ return settledExecution;
2739
+ active = settledExecution.active;
2740
+ }
2081
2741
  if (result.kind === 'completed') {
2082
2742
  const current = active.record.directReview;
2083
2743
  if (!current)
2084
2744
  return this.terminal(active, { status: 'internal-error', code: 'direct-review-result-orphaned' });
2085
2745
  if (result.report.verdict === 'needs-work') {
2086
- if (this.repairBudgetExhausted(active, maxCycles)) {
2087
- return active.record.reviewFeedback?.activeBatch
2088
- ? this.blockReviewFeedback(active, 'exhausted', 'direct-review-repair-exhausted')
2089
- : this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true }, 'direct-review-repair-exhausted');
2090
- }
2746
+ const exhausted = this.repairBudgetExhausted(active, maxCycles);
2091
2747
  const repaired = acceptNeedsWorkDirectReview(current, result.report, result.artifactSha256);
2092
2748
  const findings = [
2093
2749
  ...result.report.defects
@@ -2097,13 +2753,15 @@ export class RunIssue {
2097
2753
  .filter((finding) => finding.status === 'reopened')
2098
2754
  .map((finding) => `${finding.id}: ${finding.summary}`),
2099
2755
  ];
2100
- return this.persist(active, {
2101
- lifecycle: 'implementing',
2102
- cycle: active.record.reviewFeedback?.activeBatch
2103
- ? active.record.cycle
2104
- : (active.record.cycle + 1),
2105
- ...(active.record.reviewFeedback?.activeBatch ? {
2106
- reviewFeedback: reserveNextReviewFeedbackRound(active.record.reviewFeedback),
2756
+ active = await this.persist(active, {
2757
+ ...(!exhausted ? {
2758
+ lifecycle: 'implementing',
2759
+ cycle: active.record.reviewFeedback?.activeBatch
2760
+ ? active.record.cycle
2761
+ : (active.record.cycle + 1),
2762
+ ...(active.record.reviewFeedback?.activeBatch ? {
2763
+ reviewFeedback: reserveNextReviewFeedbackRound(active.record.reviewFeedback),
2764
+ } : {}),
2107
2765
  } : {}),
2108
2766
  directReview: repaired,
2109
2767
  reworkFindings: findings,
@@ -2111,23 +2769,48 @@ export class RunIssue {
2111
2769
  checkedChangeSha256: undefined,
2112
2770
  proofId: undefined,
2113
2771
  proofReceipt: undefined,
2772
+ reportInvocation: undefined,
2773
+ executionLease: undefined,
2114
2774
  });
2775
+ const removed = await this.removeCompletedReviewExecution(active, adoptedExecution);
2776
+ if ('status' in removed)
2777
+ return removed;
2778
+ active = removed.active;
2779
+ const released = await this.clearAndReleaseCandidate(active);
2780
+ if ('status' in released)
2781
+ return released;
2782
+ active = released.active;
2783
+ if (exhausted) {
2784
+ return active.record.reviewFeedback?.activeBatch
2785
+ ? this.blockReviewFeedback(active, 'exhausted', 'direct-review-repair-exhausted')
2786
+ : this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true }, 'direct-review-repair-exhausted');
2787
+ }
2788
+ return active;
2115
2789
  }
2116
- if (result.report.verdict !== 'approved')
2117
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'direct-review-rejected');
2118
- return this.persist(active, {
2790
+ if (result.report.verdict !== 'approved') {
2791
+ const terminal = await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'direct-review-rejected');
2792
+ await this.dependencies.git.candidateV2.removeExecution({ lease: adoptedExecution, requireProcessAbsent: true });
2793
+ return terminal;
2794
+ }
2795
+ active = await this.persist(active, {
2119
2796
  lifecycle: 'checking',
2120
2797
  directReview: acceptApprovedDirectReview(current, result.report, result.artifactSha256),
2798
+ reportInvocation: undefined,
2799
+ executionLease: undefined,
2121
2800
  });
2801
+ const removed = await this.removeCompletedReviewExecution(active, adoptedExecution);
2802
+ return 'status' in removed ? removed : removed.active;
2122
2803
  }
2123
2804
  if (result.kind === 'report-invalid') {
2124
2805
  const current = active.record.directReview;
2125
2806
  if (!current)
2126
2807
  return this.terminal(active, { status: 'internal-error', code: 'direct-review-result-orphaned' });
2127
2808
  if (current.review.reportRepairs >= MAX_DIRECT_REVIEW_REPORT_REPAIRS) {
2128
- return this.terminal(active, { status: 'internal-error', code: 'direct-review-report-malformed' }, 'direct-review-report-malformed');
2809
+ const terminal = await this.terminal(active, { status: 'internal-error', code: 'direct-review-report-malformed' }, 'direct-review-report-malformed');
2810
+ await this.dependencies.git.candidateV2.removeExecution({ lease: adoptedExecution, requireProcessAbsent: true });
2811
+ return terminal;
2129
2812
  }
2130
- const { invocation: _invocation, ...withoutInvocation } = structuredClone(current);
2813
+ const withoutInvocation = structuredClone(current);
2131
2814
  active = await this.persist(active, {
2132
2815
  directReview: {
2133
2816
  ...withoutInvocation,
@@ -2136,7 +2819,13 @@ export class RunIssue {
2136
2819
  reportRepairs: (withoutInvocation.review.reportRepairs + 1),
2137
2820
  },
2138
2821
  },
2822
+ reportInvocation: undefined,
2823
+ executionLease: undefined,
2139
2824
  });
2825
+ const removed = await this.removeCompletedReviewExecution(active, adoptedExecution);
2826
+ if ('status' in removed)
2827
+ return removed;
2828
+ active = removed.active;
2140
2829
  reportRepair = {
2141
2830
  originalReportSha256: result.originalReportSha256,
2142
2831
  originalReportBytes: Buffer.from(result.originalReportBytes),
@@ -2145,70 +2834,16 @@ export class RunIssue {
2145
2834
  continue;
2146
2835
  }
2147
2836
  if (result.kind === 'transport-failed') {
2148
- const current = active.record.directReview;
2149
- if (!current || current.review.transportRetries >= 1) {
2150
- return this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true }, 'direct-review-transport-exhausted');
2151
- }
2152
- const { invocation: _invocation, ...withoutInvocation } = structuredClone(current);
2153
- active = await this.persist(active, {
2154
- directReview: {
2155
- ...withoutInvocation,
2156
- review: { ...withoutInvocation.review, transportRetries: 1 },
2157
- },
2158
- });
2159
- continue;
2837
+ return this.invokedFailure(active, 'direct-review-transport-failed');
2160
2838
  }
2161
2839
  if (result.kind === 'safe-halt') {
2162
- active = await this.persist(active, {
2163
- lifecycle: 'safe-halt',
2164
- process: {
2165
- ...result.process,
2166
- purpose: 'code-review',
2167
- resumeLifecycle: 'implementing',
2168
- resumeReviewStage: directReview.stage,
2169
- },
2170
- });
2171
- try {
2172
- await result.waitForAbsence();
2173
- }
2174
- catch {
2175
- return this.terminal(active, { status: 'transport-failed', resumable: false }, 'direct-review-quiescence-unconfirmed');
2176
- }
2177
- return this.terminal(active, { status: 'transport-failed', resumable: false }, 'direct-review-quiescence-delayed');
2840
+ return this.invokedFailure(active, result.code, 'Code-review process ownership remains unresolved; no relaunch was attempted.');
2178
2841
  }
2179
2842
  if (result.kind === 'cancelled')
2180
2843
  return this.terminal(active, { status: 'cancelled' });
2181
2844
  return this.terminal(active, { status: 'internal-error', code: result.code });
2182
2845
  }
2183
2846
  }
2184
- async recoverDirectReviewInvocation(starting) {
2185
- const directReview = starting.record.directReview;
2186
- const invocation = directReview?.invocation;
2187
- if (!directReview || !invocation)
2188
- return starting;
2189
- const { invocation: _invocation, ...withoutInvocation } = structuredClone(directReview);
2190
- if (invocation.status === 'prepared') {
2191
- return this.persist(starting, { directReview: withoutInvocation });
2192
- }
2193
- if (invocation.status === 'launched') {
2194
- try {
2195
- await this.dependencies.waitForReviewProcessAbsence(invocation.processGroupId);
2196
- }
2197
- catch {
2198
- return this.terminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'direct-review-process-absence-unconfirmed');
2199
- }
2200
- if (directReview.review.transportRetries >= 1) {
2201
- return this.terminal(starting, { status: 'blocked', kind: 'exhausted', resumable: true }, 'direct-review-transport-exhausted');
2202
- }
2203
- return this.persist(starting, {
2204
- directReview: {
2205
- ...withoutInvocation,
2206
- review: { ...withoutInvocation.review, transportRetries: 1 },
2207
- },
2208
- });
2209
- }
2210
- return this.persist(starting, { directReview: withoutInvocation });
2211
- }
2212
2847
  async qualifyChecks(starting, config, issue, frozenCriteria) {
2213
2848
  let active = starting;
2214
2849
  let checkPolicy;
@@ -2226,70 +2861,74 @@ export class RunIssue {
2226
2861
  ? existingQualification
2227
2862
  : undefined;
2228
2863
  let repairAttempts = prior?.repairAttempts ?? 0;
2229
- const pendingInvocation = existingQualification?.repairInvocation ?? null;
2230
- if (pendingInvocation) {
2231
- if (pendingInvocation.phase === 'launched') {
2232
- try {
2233
- await this.dependencies.waitForReviewProcessAbsence(pendingInvocation.processGroupId);
2234
- }
2235
- catch {
2236
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'qualification-repair-process-absence-unconfirmed');
2237
- }
2238
- }
2239
- if (!this.dependencies.implementationAgent.recover) {
2240
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'qualification-repair-report-recovery-unavailable');
2241
- }
2242
- let recovered;
2243
- try {
2244
- recovered = await this.dependencies.implementationAgent.recover({
2245
- runId: active.record.runId,
2246
- canonicalRepository: active.record.canonicalRepository,
2247
- invocation: pendingInvocation,
2248
- });
2249
- }
2250
- catch {
2251
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'qualification-repair-report-recovery-failed');
2252
- }
2253
- if (recovered) {
2254
- const settled = await this.settleQualificationRepair(active, config, recovered);
2255
- if ('status' in settled)
2256
- return settled;
2257
- active = settled.active;
2258
- }
2259
- else {
2260
- const deniedPathsBaseline = active.record.checkQualification?.deniedPathsBaseline;
2261
- if (!deniedPathsBaseline
2262
- || await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
2263
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'denied-path-modified');
2264
- }
2265
- if (!sameFreshness(await this.dependencies.git.snapshot(active.record.worktreePath), pendingInvocation.baseline)) {
2266
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'qualification-repair-worktree-ambiguous');
2267
- }
2268
- active = await this.clearQualificationRepairInvocation(active);
2269
- }
2270
- }
2271
2864
  if (!prior) {
2272
2865
  active = await this.persist(active, {
2273
2866
  checkQualification: {
2274
2867
  version: 1, checkPolicySha256, repairAttempts, checks: [],
2275
- implementationStarted: false, repairInvocation: null,
2276
2868
  },
2277
2869
  baselineChecks: undefined,
2278
2870
  });
2279
2871
  }
2872
+ if (active.record.mutableInvocation?.operation === 'qualification-repair') {
2873
+ const findings = active.record.checkQualification?.repairFindings;
2874
+ if (!findings?.length)
2875
+ return this.invokedFailure(active, 'qualification-repair-correlation-missing');
2876
+ const deniedPathsBaseline = await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths);
2877
+ const invocationState = this.mutableInvocationState(() => active, (next) => { active = next; });
2878
+ const recovered = await this.runImplementation({
2879
+ operation: 'qualification-repair', runId: active.record.runId, worktreePath: active.record.worktreePath,
2880
+ issue, frozenCriteria, cycle: repairAttempts + 1, reworkFindings: findings, repairOnly: false,
2881
+ workflowGeneration: active.record.workflowGeneration, phaseFacts: [deniedPathsBaseline], invocationState,
2882
+ });
2883
+ if (recovered.kind === 'safe-halt')
2884
+ return this.invokedFailure(active, recovered.code);
2885
+ if (recovered.kind === 'transport-failed')
2886
+ return this.invokedFailure(active, recovered.code ?? 'qualification-repair-agent-failed');
2887
+ if (recovered.kind === 'cancelled')
2888
+ return this.terminal(active, { status: 'cancelled' });
2889
+ if (recovered.kind !== 'completed')
2890
+ return this.invokedFailure(active, 'qualification-repair-agent-failed');
2891
+ const settled = await this.settleQualificationRepair(active, config, recovered, deniedPathsBaseline);
2892
+ if ('status' in settled)
2893
+ return settled;
2894
+ active = settled.active;
2895
+ repairAttempts = active.record.checkQualification.repairAttempts;
2896
+ }
2280
2897
  while (true) {
2898
+ const captured = await this.ensureCandidateBinding(active, config, {
2899
+ kind: 'qualification', repairAttempt: repairAttempts,
2900
+ });
2901
+ if ('status' in captured)
2902
+ return captured;
2903
+ active = captured.active;
2904
+ const qualificationBinding = active.record.candidateBinding;
2281
2905
  const checks = [];
2282
2906
  const failures = [];
2283
2907
  for (const [id, command] of configuredChecks) {
2284
2908
  if (this.signal.aborted)
2285
2909
  return this.terminal(active, { status: 'cancelled' });
2910
+ if (this.dependencies.checks.supportsLaunchOwnership !== true) {
2911
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'candidate-check-launch-ownership-required');
2912
+ }
2913
+ const execution = await this.prepareCandidateExecution(active, config, 'qualification-check', `${id}:${checkPolicySha256}`);
2914
+ if ('status' in execution)
2915
+ return execution;
2916
+ active = execution.active;
2917
+ const lease = execution.lease;
2286
2918
  let check;
2287
2919
  try {
2288
2920
  check = await this.dependencies.checks.run({
2289
2921
  id, command, source: checkPolicy.source,
2290
- cwd: active.record.worktreePath,
2922
+ cwd: lease.path,
2291
2923
  phase: 'qualification',
2292
2924
  signal: this.signal,
2925
+ onLaunched: async ({ pid, processGroupId }) => {
2926
+ active = await this.persist(active, {
2927
+ executionLease: this.dependencies.git.candidateV2.markExecutionLaunched({
2928
+ lease, pid, processGroupId, launchedAt: this.timestamp(),
2929
+ }),
2930
+ });
2931
+ },
2293
2932
  });
2294
2933
  }
2295
2934
  catch (error) {
@@ -2298,9 +2937,18 @@ export class RunIssue {
2298
2937
  }
2299
2938
  return this.invokedFailure(active, 'qualification-check-execution-failed', error instanceof Error ? error.message : 'The qualification check process did not start or settle. Retry the same run.');
2300
2939
  }
2940
+ const settledExecution = await this.settleCandidateExecution(active, config);
2941
+ if ('status' in settledExecution)
2942
+ return settledExecution;
2943
+ active = settledExecution.active;
2301
2944
  if (this.signal.aborted)
2302
2945
  return this.terminal(active, { status: 'cancelled' });
2303
- checks.push({ id, command, status: check.status, outputSha256: check.outputSha256 });
2946
+ checks.push({
2947
+ id, command, status: check.status, outputSha256: check.outputSha256,
2948
+ bindingId: qualificationBinding.bindingId,
2949
+ candidateTreeSha: qualificationBinding.candidateTreeSha,
2950
+ checkPolicySha256,
2951
+ });
2304
2952
  if (check.status === 'failed') {
2305
2953
  failures.push(`Pre-implementation scoped check ${id} failed (${command}):\n${check.output.toString('utf8').slice(0, 8 * 1024)}`);
2306
2954
  }
@@ -2308,19 +2956,28 @@ export class RunIssue {
2308
2956
  active = await this.persist(active, {
2309
2957
  checkQualification: {
2310
2958
  version: 1, checkPolicySha256, repairAttempts, checks,
2311
- implementationStarted: false, repairInvocation: null,
2312
2959
  },
2313
2960
  baselineChecks: undefined,
2314
2961
  });
2315
- if (failures.length === 0)
2316
- return { active };
2962
+ if (failures.length === 0) {
2963
+ const released = await this.clearAndReleaseCandidate(active);
2964
+ return 'status' in released ? released : { active: released.active };
2965
+ }
2317
2966
  if (repairAttempts >= config.runner.maxCycles) {
2318
2967
  return this.terminal(active, { status: 'blocked', kind: 'exhausted', resumable: true }, 'qualification-repair-exhausted');
2319
2968
  }
2320
2969
  if (!await this.authorized(active, config)) {
2321
2970
  return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
2322
2971
  }
2972
+ const released = await this.clearAndReleaseCandidate(active);
2973
+ if ('status' in released)
2974
+ return released;
2975
+ active = released.active;
2323
2976
  const deniedPathsBaseline = await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths);
2977
+ active = await this.persist(active, {
2978
+ checkQualification: { ...active.record.checkQualification, repairFindings: [...failures] },
2979
+ });
2980
+ const invocationState = this.mutableInvocationState(() => active, (next) => { active = next; });
2324
2981
  const repair = await this.runImplementation({
2325
2982
  operation: 'qualification-repair',
2326
2983
  runId: active.record.runId,
@@ -2331,82 +2988,37 @@ export class RunIssue {
2331
2988
  reworkFindings: failures,
2332
2989
  repairOnly: false,
2333
2990
  workflowGeneration: active.record.workflowGeneration,
2334
- onPrepared: async (invocation) => {
2335
- active = await this.persist(active, {
2336
- checkQualification: {
2337
- ...active.record.checkQualification,
2338
- implementationStarted: false,
2339
- deniedPathsBaseline,
2340
- repairInvocation: {
2341
- ...structuredClone(invocation), phase: 'prepared',
2342
- pid: null, processGroupId: null, launchedAt: null,
2343
- },
2344
- },
2345
- });
2346
- },
2347
- onLaunched: async (invocation) => {
2348
- const current = active.record.checkQualification?.repairInvocation;
2349
- if (!current || current.phase !== 'prepared' || current.attemptId !== invocation.attemptId) {
2350
- throw new Error('qualification repair launch mismatch');
2351
- }
2352
- repairAttempts = (repairAttempts + 1);
2353
- active = await this.persist(active, {
2354
- checkQualification: {
2355
- ...active.record.checkQualification, repairAttempts,
2356
- repairInvocation: {
2357
- ...current, phase: 'launched', pid: invocation.pid,
2358
- processGroupId: invocation.processGroupId, launchedAt: invocation.launchedAt,
2359
- },
2360
- },
2361
- });
2362
- },
2991
+ phaseFacts: [deniedPathsBaseline],
2992
+ invocationState,
2363
2993
  });
2364
2994
  if (repair.kind === 'safe-halt') {
2365
- active = await this.persist(active, {
2366
- lifecycle: 'safe-halt',
2367
- process: {
2368
- ...repair.process,
2369
- purpose: 'implementation',
2370
- resumeLifecycle: 'implementing',
2371
- resumeReviewStage: null,
2372
- },
2373
- });
2374
- while (true) {
2375
- try {
2376
- await repair.waitForAbsence();
2377
- break;
2378
- }
2379
- catch {
2380
- await new Promise((resolveWait) => setTimeout(resolveWait, 25));
2381
- }
2382
- }
2383
- if (await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
2384
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'denied-path-modified');
2385
- }
2386
- return this.terminal(active, { status: 'transport-failed', resumable: false }, 'process-quiescence-delayed');
2995
+ return this.invokedFailure(active, repair.code);
2387
2996
  }
2388
2997
  if (repair.kind !== 'completed'
2389
2998
  && await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
2390
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'denied-path-modified');
2999
+ return this.invokedFailure(active, 'qualification-repair-effect-unresolved');
2391
3000
  }
2392
3001
  if (repair.kind === 'cancelled')
2393
3002
  return this.terminal(active, { status: 'cancelled' });
2394
3003
  if (repair.kind !== 'completed') {
2395
3004
  return this.invokedFailure(active, 'qualification-repair-agent-failed', 'Qualification repair did not complete. Retry the same run.');
2396
3005
  }
2397
- const settled = await this.settleQualificationRepair(active, config, repair);
3006
+ const settled = await this.settleQualificationRepair(active, config, repair, deniedPathsBaseline);
2398
3007
  if ('status' in settled)
2399
3008
  return settled;
2400
3009
  active = settled.active;
3010
+ repairAttempts = active.record.checkQualification.repairAttempts;
2401
3011
  }
2402
3012
  }
2403
- async settleQualificationRepair(active, config, repair) {
2404
- const deniedPathsBaseline = active.record.checkQualification?.deniedPathsBaseline;
2405
- if (!deniedPathsBaseline
2406
- || await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
3013
+ async settleQualificationRepair(active, config, repair, deniedPathsBaseline) {
3014
+ if (await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, config.deny.readPaths) !== deniedPathsBaseline) {
3015
+ active = await this.persist(active, { mutableInvocation: undefined });
2407
3016
  return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'denied-path-modified');
2408
3017
  }
2409
- active = await this.clearQualificationRepairInvocation(active);
3018
+ const qualification = { ...active.record.checkQualification };
3019
+ delete qualification.repairFindings;
3020
+ qualification.repairAttempts = (qualification.repairAttempts + 1);
3021
+ active = await this.persist(active, { checkQualification: qualification, mutableInvocation: undefined });
2410
3022
  let report;
2411
3023
  try {
2412
3024
  report = validateImplementationReport(repair.report);
@@ -2426,71 +3038,6 @@ export class RunIssue {
2426
3038
  }
2427
3039
  return { active };
2428
3040
  }
2429
- async clearQualificationRepairInvocation(active) {
2430
- const qualification = { ...active.record.checkQualification, implementationStarted: false, repairInvocation: null };
2431
- delete qualification.deniedPathsBaseline;
2432
- return this.persist(active, { checkQualification: qualification });
2433
- }
2434
- hasRecordedImplementationStart(record, config) {
2435
- const qualification = record.checkQualification;
2436
- if (!qualification)
2437
- return false;
2438
- let policy;
2439
- try {
2440
- policy = resolveIssueCheckPolicy(record.issueSnapshot.body, config.checks);
2441
- }
2442
- catch {
2443
- return false;
2444
- }
2445
- return qualification.implementationStarted === true
2446
- && qualification.checkPolicySha256 === sha256(canonicalJson(policy.checks))
2447
- && sameCheckPolicy(qualification.checks, policy.checks)
2448
- && qualification.checks.every((check) => check.status === 'passed');
2449
- }
2450
- async recoverTerminalReviewReport(active, issue, config, frozenCriteria) {
2451
- const directReview = active.record.directReview;
2452
- const allowLegacyMalformed = directReview?.terminalCode === undefined
2453
- && active.record.outcomeEvidenceId === `evidence:${active.record.runId}:direct-review-report-malformed`;
2454
- if (!issue || !directReview || active.record.intent || active.record.process
2455
- || active.record.terminalOutcome?.status !== 'internal-error'
2456
- || directReview.status !== 'terminal' || directReview.terminalOutcome?.status !== 'internal-error'
2457
- || !canRecoverTerminalDirectReviewReport(directReview, { allowLegacyMalformed })
2458
- || !this.isAuthorizedIssue(issue, active.record, config))
2459
- return undefined;
2460
- let worktree;
2461
- try {
2462
- worktree = await this.dependencies.git.inspectWorktree({
2463
- worktreePath: active.record.worktreePath,
2464
- branchName: active.record.branchName,
2465
- baseSha: active.record.baseSha,
2466
- });
2467
- }
2468
- catch {
2469
- return undefined;
2470
- }
2471
- if (worktree !== 'matching'
2472
- || await this.dependencies.git.getHead(active.record.worktreePath) !== expectedImplementationHead(active.record))
2473
- return undefined;
2474
- const changedFiles = await this.dependencies.git.listChangedFiles(active.record.worktreePath);
2475
- if (changedFiles.length === 0 || !active.record.routeReceipt)
2476
- return undefined;
2477
- const targetFingerprint = directReviewTargetFingerprint({
2478
- snapshot: await this.dependencies.git.snapshot(active.record.worktreePath),
2479
- changedFiles,
2480
- routeDecisionSha256: active.record.routeReceipt.decisionSha256,
2481
- workflowGenerationHash: active.record.workflowGeneration.generationHash,
2482
- cycle: active.record.cycle,
2483
- frozenCriteria,
2484
- });
2485
- if (targetFingerprint !== directReview.targetFingerprint)
2486
- return undefined;
2487
- return this.persist(active, {
2488
- lifecycle: 'implementing',
2489
- directReview: recoverTerminalDirectReviewReport(directReview, { allowLegacyMalformed }),
2490
- terminalOutcome: undefined,
2491
- outcomeEvidenceId: undefined,
2492
- });
2493
- }
2494
3041
  async startNextCycle(active, findings, sources) {
2495
3042
  if (active.record.directReview?.status === 'clear') {
2496
3043
  const fallbackProvenance = active.record.lifecycle === 'proving' ? 'proof' : 'check';
@@ -2511,6 +3058,8 @@ export class RunIssue {
2511
3058
  }));
2512
3059
  return this.persist(active, {
2513
3060
  lifecycle: 'implementing',
3061
+ changeBindingVersion: undefined,
3062
+ candidateBinding: undefined,
2514
3063
  cycle: active.record.reviewFeedback?.activeBatch
2515
3064
  ? active.record.cycle
2516
3065
  : (active.record.cycle + 1),
@@ -2528,6 +3077,8 @@ export class RunIssue {
2528
3077
  active = await this.persist(active, { lifecycle: 'reworking', reworkFindings: [...findings] });
2529
3078
  return this.persist(active, {
2530
3079
  lifecycle: 'implementing',
3080
+ changeBindingVersion: undefined,
3081
+ candidateBinding: undefined,
2531
3082
  cycle: active.record.reviewFeedback?.activeBatch
2532
3083
  ? active.record.cycle
2533
3084
  : (active.record.cycle + 1),
@@ -2540,12 +3091,39 @@ export class RunIssue {
2540
3091
  proofReceipt: undefined,
2541
3092
  });
2542
3093
  }
3094
+ async startNextCycleFromCandidate(active, findings, sources) {
3095
+ const binding = active.record.candidateBinding;
3096
+ const candidate = this.dependencies.git.candidateV2;
3097
+ if (!binding || !candidate)
3098
+ return this.startNextCycle(active, findings, sources);
3099
+ if (active.record.executionLease) {
3100
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-execution-not-quiescent');
3101
+ }
3102
+ const normalized = await candidate.normalizeSharedIndex({
3103
+ worktreePath: active.record.worktreePath,
3104
+ expectedHeadSha: expectedImplementationHead(active.record),
3105
+ });
3106
+ if (normalized.kind === 'failed')
3107
+ return this.mapCandidateFailure(active, normalized.code);
3108
+ const transitioned = await this.startNextCycle(active, findings, sources);
3109
+ const released = await candidate.releasePin({ binding, expectedPinnedCommitSha: binding.candidateCommitSha });
3110
+ if (released.kind === 'failed') {
3111
+ return this.invokedFailure(transitioned, 'candidate-pin-release-pending', 'The next repair cycle is durable; orphan reconciliation will retry exact pin cleanup.');
3112
+ }
3113
+ return transitioned;
3114
+ }
2543
3115
  repairBudgetExhausted(active, maxCycles) {
2544
3116
  return active.record.reviewFeedback?.activeBatch
2545
3117
  ? active.record.reviewFeedback.repairRound >= 3
2546
3118
  : active.record.cycle >= maxCycles;
2547
3119
  }
2548
3120
  async blockReviewFeedback(active, kind, evidenceCode) {
3121
+ if (active.record.mutableInvocation) {
3122
+ const reconciled = await this.reconcileMutableBeforeTerminal(active);
3123
+ if ('status' in reconciled)
3124
+ return reconciled;
3125
+ active = reconciled.active;
3126
+ }
2549
3127
  const feedback = active.record.reviewFeedback;
2550
3128
  if (!feedback?.activeBatch)
2551
3129
  return this.terminal(active, { status: 'blocked', kind, resumable: false }, evidenceCode);
@@ -2576,6 +3154,7 @@ export class RunIssue {
2576
3154
  }
2577
3155
  if (canReduceAuthority && !sameStrings(issue.labels, blockedLabels)) {
2578
3156
  try {
3157
+ await this.markV3ExternalEffect(active);
2579
3158
  await this.dependencies.issues.setLabels(active.record.issueNumber, blockedLabels);
2580
3159
  }
2581
3160
  catch {
@@ -2598,7 +3177,7 @@ export class RunIssue {
2598
3177
  ...(directReview ? { directReview } : {}),
2599
3178
  terminalOutcome,
2600
3179
  outcomeEvidenceId: evidence.id,
2601
- process: undefined,
3180
+ reportInvocation: undefined,
2602
3181
  intent: undefined,
2603
3182
  });
2604
3183
  return publicOutcome(terminalOutcome);
@@ -2628,7 +3207,21 @@ export class RunIssue {
2628
3207
  const evidence = await this.dependencies.writeEvidence({ runId: active.record.runId, code, summary });
2629
3208
  return { status: 'transport-failed', resumable: true, evidencePath: evidence.path };
2630
3209
  }
3210
+ async markV3ExternalEffect(active) {
3211
+ if (active.state.version !== 3)
3212
+ return;
3213
+ if (!this.dependencies.runRecords.markPublicationEffectPossible) {
3214
+ throw new Error('run-state publication watermark is unavailable');
3215
+ }
3216
+ await this.dependencies.runRecords.markPublicationEffectPossible();
3217
+ }
2631
3218
  async terminal(active, outcome, evidenceCode = outcome.status, retainIntent = false) {
3219
+ if (active.record.mutableInvocation) {
3220
+ const reconciled = await this.reconcileMutableBeforeTerminal(active);
3221
+ if ('status' in reconciled)
3222
+ return reconciled;
3223
+ active = reconciled.active;
3224
+ }
2632
3225
  if (active.record.reviewFeedback?.activeBatch && outcome.status !== 'review-ready') {
2633
3226
  return this.blockReviewFeedback(active, outcome.status === 'blocked' && outcome.kind === 'exhausted' ? 'exhausted' : 'safety', evidenceCode);
2634
3227
  }
@@ -2637,6 +3230,68 @@ export class RunIssue {
2637
3230
  }
2638
3231
  return this.persistTerminal(active, outcome, evidenceCode, retainIntent);
2639
3232
  }
3233
+ async reconcileMutableBeforeTerminal(starting) {
3234
+ let active = starting;
3235
+ const invocation = active.record.mutableInvocation;
3236
+ if (!invocation)
3237
+ return { active };
3238
+ let deniedPathsBaseline;
3239
+ try {
3240
+ deniedPathsBaseline = await this.dependencies.git.fingerprintDeniedPaths(active.record.worktreePath, active.config.deny.readPaths);
3241
+ }
3242
+ catch {
3243
+ return this.invokedFailure(active, 'mutable-operation-terminal-baseline-unavailable');
3244
+ }
3245
+ const qualification = invocation.operation === 'qualification-repair';
3246
+ const feedback = invocation.operation === 'review-feedback-implementation';
3247
+ const repairFindings = invocation.context.reworkFindings;
3248
+ if (qualification && !repairFindings?.length) {
3249
+ return this.invokedFailure(active, 'qualification-repair-correlation-missing');
3250
+ }
3251
+ const activeBatch = feedback ? active.record.reviewFeedback?.activeBatch : undefined;
3252
+ const directReview = feedback ? active.record.directReview : undefined;
3253
+ if (feedback && (!activeBatch || !directReview)) {
3254
+ return this.invokedFailure(active, 'review-feedback-implementation-correlation-missing');
3255
+ }
3256
+ const feedbackProjection = activeBatch && directReview
3257
+ ? projectReviewFeedbackBatch(activeBatch, directReview.targetRevision)
3258
+ : undefined;
3259
+ const phaseFacts = qualification
3260
+ ? [deniedPathsBaseline]
3261
+ : [
3262
+ deniedPathsBaseline,
3263
+ sha256(canonicalJson(resolveIssueCheckPolicy(active.record.issueSnapshot.body, active.config.checks).checks)),
3264
+ canonicalJson(active.record.checkQualification?.checks ?? []),
3265
+ ];
3266
+ const invocationState = this.mutableInvocationState(() => active, (next) => { active = next; });
3267
+ let settled;
3268
+ try {
3269
+ settled = await this.dependencies.implementationAgent.settle({
3270
+ operation: invocation.operation,
3271
+ runId: active.record.runId,
3272
+ worktreePath: active.record.worktreePath,
3273
+ issue: publicIssueSnapshot(active.record.issueSnapshot),
3274
+ frozenCriteria: active.record.frozenCriteria,
3275
+ cycle: qualification ? active.record.checkQualification.repairAttempts + 1 : active.record.cycle,
3276
+ reworkFindings: repairFindings ?? [],
3277
+ repairOnly: invocation.context.repairOnly,
3278
+ workflowGeneration: active.record.workflowGeneration,
3279
+ ...(feedbackProjection ? {
3280
+ reviewFeedbackRound: active.record.reviewFeedback.repairRound,
3281
+ reviewFeedback: feedbackProjection.workerFeedback,
3282
+ } : {}),
3283
+ phaseFacts,
3284
+ invocationState,
3285
+ signal: this.signal,
3286
+ });
3287
+ }
3288
+ catch {
3289
+ return this.invokedFailure(active, 'mutable-operation-terminal-settlement-failed');
3290
+ }
3291
+ return settled.kind === 'settled'
3292
+ ? { active }
3293
+ : this.invokedFailure(active, settled.code, 'Mutable invocation remains fenced before terminal reconciliation.');
3294
+ }
2640
3295
  async publishBlockedTerminal(starting, outcome, evidenceCode) {
2641
3296
  let active = starting;
2642
3297
  const autoBlocked = [active.config.github.labels.auto.name, active.config.github.labels.blocked.name].sort();
@@ -2675,6 +3330,7 @@ export class RunIssue {
2675
3330
  return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
2676
3331
  }
2677
3332
  try {
3333
+ await this.markV3ExternalEffect(active);
2678
3334
  await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
2679
3335
  }
2680
3336
  catch {
@@ -2714,6 +3370,7 @@ export class RunIssue {
2714
3370
  return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
2715
3371
  }
2716
3372
  try {
3373
+ await this.markV3ExternalEffect(active);
2717
3374
  await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
2718
3375
  }
2719
3376
  catch {
@@ -2733,8 +3390,10 @@ export class RunIssue {
2733
3390
  lifecycle: outcome.status,
2734
3391
  terminalOutcome,
2735
3392
  outcomeEvidenceId: evidence.id,
2736
- process: undefined,
3393
+ reportInvocation: undefined,
2737
3394
  };
3395
+ if (active.record.reportInvocation?.operation === 'code-review')
3396
+ changes.executionLease = undefined;
2738
3397
  if (outcome.status !== 'review-ready' && active.record.directReview && active.record.directReview.status !== 'terminal') {
2739
3398
  changes.directReview = projectTerminalDirectReview(active.record.directReview, outcome.status === 'blocked'
2740
3399
  ? { status: 'blocked', kind: outcome.kind }
@@ -2763,7 +3422,7 @@ export class RunIssue {
2763
3422
  });
2764
3423
  }
2765
3424
  }
2766
- if (active.record.lifecycle === 'triaging' || (active.record.lifecycle === 'safe-halt' && !active.record.routeReceipt)) {
3425
+ if (active.record.lifecycle === 'triaging') {
2767
3426
  changes.routeExecution = undefined;
2768
3427
  changes.routeReceipt = undefined;
2769
3428
  }
@@ -2780,8 +3439,8 @@ export class RunIssue {
2780
3439
  const evidence = await this.dependencies.writeEvidence({ runId: `issue-${issueNumber}`, code: 'cancelled', summary: 'Cancelled.' });
2781
3440
  return { status: 'cancelled', evidencePath: evidence.path };
2782
3441
  }
2783
- async preClaimTransport(issueNumber) {
2784
- const evidence = await this.dependencies.writeEvidence({ runId: `issue-${issueNumber}`, code: 'issue-read-failed', summary: 'Issue read failed.' });
3442
+ async preClaimTransport(issueNumber, code = 'issue-read-failed', summary = 'Issue read failed.') {
3443
+ const evidence = await this.dependencies.writeEvidence({ runId: `issue-${issueNumber}`, code, summary });
2785
3444
  return { status: 'transport-failed', resumable: true, evidencePath: evidence.path };
2786
3445
  }
2787
3446
  timestamp() {
@@ -3018,6 +3677,25 @@ function sameFreshness(left, right) {
3018
3677
  function expectedImplementationHead(record) {
3019
3678
  return record.reviewFeedback?.activeBatch?.priorPublishedHeadSha ?? record.baseSha;
3020
3679
  }
3680
+ function pendingCandidateBoundary(record) {
3681
+ if (record.lifecycle !== 'implementing')
3682
+ return undefined;
3683
+ const feedback = record.reviewFeedback;
3684
+ if (feedback?.activeBatch && feedback.repairRound >= 1 && feedback.repairRound <= 3) {
3685
+ return {
3686
+ kind: 'review-feedback',
3687
+ batchId: feedback.activeBatch.batchId,
3688
+ repairRound: feedback.repairRound,
3689
+ };
3690
+ }
3691
+ if (record.mutableInvocation?.operation === 'qualification-repair' && record.checkQualification) {
3692
+ return {
3693
+ kind: 'qualification',
3694
+ repairAttempt: record.checkQualification.repairAttempts,
3695
+ };
3696
+ }
3697
+ return { kind: 'implementation-cycle', cycle: record.cycle };
3698
+ }
3021
3699
  function commentsWithMarker(issue, marker) {
3022
3700
  return issue.comments.filter((comment) => comment.body.split('\n')[0] === marker);
3023
3701
  }