codex-orchestrator 2.0.13 → 2.0.15

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 (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/src/v2/acceptance-proof.js +1 -4
  3. package/dist/src/v2/acceptance-proof.js.map +1 -1
  4. package/dist/src/v2/adapters/gh-issue-adapter.js +2 -0
  5. package/dist/src/v2/adapters/gh-issue-adapter.js.map +1 -1
  6. package/dist/src/v2/containment.d.ts +1 -0
  7. package/dist/src/v2/containment.d.ts.map +1 -1
  8. package/dist/src/v2/containment.js +8 -1
  9. package/dist/src/v2/containment.js.map +1 -1
  10. package/dist/src/v2/implementation-report.d.ts +5 -1
  11. package/dist/src/v2/implementation-report.d.ts.map +1 -1
  12. package/dist/src/v2/implementation-report.js +46 -1
  13. package/dist/src/v2/implementation-report.js.map +1 -1
  14. package/dist/src/v2/pending-effect-settlement.d.ts +2 -2
  15. package/dist/src/v2/pending-effect-settlement.d.ts.map +1 -1
  16. package/dist/src/v2/pending-effect-settlement.js +3 -3
  17. package/dist/src/v2/pending-effect-settlement.js.map +1 -1
  18. package/dist/src/v2/review-feedback-coordinator.d.ts +7 -1
  19. package/dist/src/v2/review-feedback-coordinator.d.ts.map +1 -1
  20. package/dist/src/v2/review-feedback-coordinator.js +84 -4
  21. package/dist/src/v2/review-feedback-coordinator.js.map +1 -1
  22. package/dist/src/v2/review-feedback.d.ts +14 -2
  23. package/dist/src/v2/review-feedback.d.ts.map +1 -1
  24. package/dist/src/v2/review-feedback.js +74 -6
  25. package/dist/src/v2/review-feedback.js.map +1 -1
  26. package/dist/src/v2/run-issue.d.ts +16 -7
  27. package/dist/src/v2/run-issue.d.ts.map +1 -1
  28. package/dist/src/v2/run-issue.js +629 -286
  29. package/dist/src/v2/run-issue.js.map +1 -1
  30. package/dist/src/v2/run-state-projections.d.ts +0 -13
  31. package/dist/src/v2/run-state-projections.d.ts.map +1 -1
  32. package/dist/src/v2/run-state-projections.js +0 -20
  33. package/dist/src/v2/run-state-projections.js.map +1 -1
  34. package/dist/src/v2/run-store.d.ts +51 -15
  35. package/dist/src/v2/run-store.d.ts.map +1 -1
  36. package/dist/src/v2/run-store.js +111 -25
  37. package/dist/src/v2/run-store.js.map +1 -1
  38. package/dist/src/v2/runtime.d.ts +10 -0
  39. package/dist/src/v2/runtime.d.ts.map +1 -1
  40. package/dist/src/v2/runtime.js +23 -16
  41. package/dist/src/v2/runtime.js.map +1 -1
  42. package/dist/src/v2/validation-progression.d.ts +1 -1
  43. package/dist/src/v2/validation-progression.d.ts.map +1 -1
  44. package/dist/src/v2/validation-progression.js.map +1 -1
  45. package/internal-workflow/manifest.json +1 -1
  46. package/internal-workflow/operations/implementation/SKILL.md +6 -1
  47. package/internal-workflow/schemas/implementation-report-v1.json +1 -1
  48. package/internal-workflow/skills/tickets-orchestrator/SKILL.md +4 -0
  49. package/internal-workflow/skills/to-spec/SKILL.md +17 -8
  50. package/internal-workflow/skills/to-spec/evals/evals.json +6 -0
  51. package/internal-workflow/skills/to-tickets/SKILL.md +29 -2
  52. package/internal-workflow/skills/to-tickets/evals/evals.json +12 -0
  53. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import { dirname, resolve } from 'node:path';
2
2
  import { checkedChangePayloadSha256, } from './checked-change.js';
3
3
  import { parseAgentAutoConfig } from './config.js';
4
- import { canonicalJson, containsCredentialEvidence, parseJsonWithoutDuplicateKeys, sha256 } from './containment.js';
4
+ import { canonicalJson, containsCredentialEvidence, containsHostIdentityEvidence, parseJsonWithoutDuplicateKeys, sha256 } from './containment.js';
5
5
  import { findDeniedPathMatch } from './adapters/path-policy.js';
6
6
  import { adoptActiveAttempt, confirmActiveAttemptCleanup, createActiveAttempt, launchActiveAttempt, observeActiveAttempt, } from './active-attempt.js';
7
7
  import { validateImplementationReport } from './implementation-report.js';
@@ -11,9 +11,9 @@ import { CandidateProofInspectionError, ProofLaunchAuthorizationError } from './
11
11
  import { CheckProcessQuiescenceError, resolveIssueCheckPolicy } from './issue-check-policy.js';
12
12
  import { IssueInitializationUnrecoverableError, WorkflowGenerationUnrecoverableError, } from './run-store.js';
13
13
  import { createPendingEffect } from './run-store.js';
14
- import { blockReviewFeedback, initializeReviewFeedback, projectReviewFeedbackBatch, publishReviewFeedback, reserveNextReviewFeedbackRound, } from './review-feedback.js';
14
+ import { blockReviewFeedback, initializeReviewFeedback, projectReviewFeedbackBatch, publishReviewFeedback, respondReviewFeedback, reserveNextReviewFeedbackRound, settleReviewFeedbackResponse, } from './review-feedback.js';
15
15
  import { createIssueDeliveryAuthority } from './delivery-authority.js';
16
- import { blockedLabelPolicy, blockedLabelProjection, claimComment, claimRunId, freezeCriteria, historicalClaimCounts, isClaimMarkerComment, isExactClaimMarkerLine, isAdoptableAttempt, observedHistoricalClaimKeys, outcomeEvidenceBytes, publicOutcome, publicIssueSnapshot, refreshClaimedIssueSnapshot, snapshotIssue, sameInspectionIdentity, sameStrings, semanticChangesMatch, } from './run-state-projections.js';
16
+ import { claimComment, claimRunId, freezeCriteria, historicalClaimCounts, isClaimMarkerComment, isExactClaimMarkerLine, isAdoptableAttempt, observedHistoricalClaimKeys, outcomeEvidenceBytes, publicOutcome, publicIssueSnapshot, refreshClaimedIssueSnapshot, snapshotIssue, sameInspectionIdentity, sameStrings, semanticChangesMatch, } from './run-state-projections.js';
17
17
  import { applyValidationTransition, nextValidationTransition, projectValidationFeedbackActivation, projectValidationProofPassed, projectValidationProofStart, projectValidationRepair, projectValidationReviewApproved, projectValidationReviewNeedsWork, projectValidationReviewReportRepair, projectValidationReviewTransportRetry, } from './validation-progression.js';
18
18
  import { settleCommentEffect, settleCommitEffect, settleCleanupEffect, settleDraftPullRequestEffect, settleLabelsEffect, settlePushEffect, } from './pending-effect-settlement.js';
19
19
  export class OwnerLockSafetyError extends Error {
@@ -77,7 +77,7 @@ export class RunIssue {
77
77
  if (!observation || observation.state !== 'OPEN' || !this.hasTrustedClaim(observation, active.record)) {
78
78
  return { result: await this.publicationDiverged(active, 'claim-comment-observation-diverged') };
79
79
  }
80
- if (sameStrings(observation.labels, expectedLabels))
80
+ if (claimLabelProjectionSettled(observation.labels, config))
81
81
  return { active };
82
82
  const labels = new Set(observation.labels);
83
83
  if (!labels.has(config.github.labels.auto.name)
@@ -98,7 +98,7 @@ export class RunIssue {
98
98
  observation = await this.readIssue(issueNumber);
99
99
  if (!observation || !this.hasTrustedClaim(observation, active.record))
100
100
  return 'diverged';
101
- return sameStrings(observation.labels, expectedLabels) ? 'confirmed' : 'absent';
101
+ return claimLabelProjectionSettled(observation.labels, config) ? 'confirmed' : 'absent';
102
102
  },
103
103
  invoke: () => this.dependencies.issues.setLabels(issueNumber, expectedLabels),
104
104
  });
@@ -241,7 +241,7 @@ export class RunIssue {
241
241
  });
242
242
  if (settlement.status === 'unauthorized') {
243
243
  return this.signal.aborted
244
- ? await this.terminal(await this.clearEffect(active), { status: 'cancelled' })
244
+ ? await this.terminal(await this.confirmEffect(active), { status: 'cancelled' })
245
245
  : await this.revoked(active);
246
246
  }
247
247
  if (settlement.status === 'unknown')
@@ -273,7 +273,7 @@ export class RunIssue {
273
273
  });
274
274
  if (settlement.status === 'unauthorized') {
275
275
  return this.signal.aborted
276
- ? await this.terminal(await this.clearEffect(active), { status: 'cancelled' })
276
+ ? await this.terminal(await this.confirmEffect(active), { status: 'cancelled' })
277
277
  : await this.revoked(active);
278
278
  }
279
279
  if (settlement.status === 'unknown')
@@ -320,7 +320,7 @@ export class RunIssue {
320
320
  });
321
321
  if (settlement.status === 'unauthorized') {
322
322
  return this.signal.aborted
323
- ? await this.terminal(await this.clearEffect(active), { status: 'cancelled' })
323
+ ? await this.terminal(await this.confirmEffect(active), { status: 'cancelled' })
324
324
  : await this.revoked(active);
325
325
  }
326
326
  if (settlement.status === 'unknown')
@@ -332,74 +332,9 @@ export class RunIssue {
332
332
  const pullRequest = await this.dependencies.pullRequests.findOpen({ headBranch: branchName, baseBranch: config.github.baseBranch });
333
333
  if (!pullRequest || pullRequest.body !== prBody)
334
334
  return await this.publicationDiverged(active, 'pr-missing-before-handoff');
335
- const handoffMarker = `<!-- codex-orchestrator:run:${runId}:handoff -->`;
336
- const handoffBody = `${handoffMarker}\nReview-ready draft PR: ${pullRequest.url}`;
337
- if (active.record.pendingEffect?.kind === 'handoff-comment' || !active.record.pendingEffect) {
338
- if (!active.record.pendingEffect) {
339
- active = await this.persist(active, {
340
- pendingEffect: { kind: 'handoff-comment', issueNumber, marker: handoffMarker, bodySha256: sha256(handoffBody) },
341
- });
342
- }
343
- const pendingEffect = active.record.pendingEffect;
344
- if (pendingEffect?.kind !== 'handoff-comment' || pendingEffect.issueNumber !== issueNumber
345
- || pendingEffect.marker !== handoffMarker || pendingEffect.bodySha256 !== sha256(handoffBody)) {
346
- return this.publicationDiverged(active, 'handoff-comment-pendingEffect-diverged');
347
- }
348
- const settlement = await settleCommentEffect(pendingEffect, {
349
- observe: async () => {
350
- const observation = await this.readIssue(issueNumber);
351
- if (!observation)
352
- return 'diverged';
353
- const matching = commentsWithMarker(observation, handoffMarker);
354
- if (matching.length === 0)
355
- return 'absent';
356
- return matching.length === 1 && sha256(matching[0].body) === sha256(handoffBody) ? 'confirmed' : 'diverged';
357
- },
358
- authorize: async () => !this.signal.aborted && await this.authorized(active, config),
359
- invoke: () => this.dependencies.issues.postComment(issueNumber, handoffBody),
360
- });
361
- if (settlement.status === 'unauthorized') {
362
- return this.signal.aborted
363
- ? await this.terminal(await this.clearEffect(active), { status: 'cancelled' })
364
- : await this.revoked(active);
365
- }
366
- if (settlement.status === 'unknown')
367
- return await this.invokedFailure(active, 'handoff-comment-delivery-unknown');
368
- if (settlement.status !== 'confirmed')
369
- return await this.publicationDiverged(active, 'handoff-comment-observation-diverged');
370
- active = await this.confirmEffect(active);
371
- }
372
- const terminalLabels = [config.github.labels.review.name];
373
- if (active.record.pendingEffect?.kind === 'final-labels' || !active.record.pendingEffect) {
374
- if (!active.record.pendingEffect)
375
- active = await this.persist(active, { pendingEffect: { kind: 'final-labels', issueNumber, expected: terminalLabels } });
376
- const pendingEffect = active.record.pendingEffect;
377
- if (pendingEffect?.kind !== 'final-labels' || pendingEffect.issueNumber !== issueNumber
378
- || !sameStrings(pendingEffect.expected, terminalLabels)) {
379
- return this.publicationDiverged(active, 'terminal-labels-pendingEffect-diverged');
380
- }
381
- const settlement = await settleLabelsEffect(pendingEffect, {
382
- observe: async () => {
383
- const observation = await this.readIssue(issueNumber);
384
- return !observation ? 'diverged' : sameStrings(observation.labels, terminalLabels) ? 'confirmed' : 'absent';
385
- },
386
- authorize: async () => !this.signal.aborted && await this.authorized(active, config),
387
- invoke: () => this.dependencies.issues.setLabels(issueNumber, terminalLabels),
388
- });
389
- if (settlement.status === 'unauthorized') {
390
- return this.signal.aborted
391
- ? await this.terminal(await this.clearEffect(active), { status: 'cancelled' })
392
- : await this.revoked(active);
393
- }
394
- if (settlement.status === 'unknown')
395
- return await this.invokedFailure(active, 'terminal-labels-delivery-unknown');
396
- if (settlement.status !== 'confirmed')
397
- return await this.publicationDiverged(active, 'terminal-labels-diverged');
398
- active = await this.confirmEffect(active);
399
- }
400
- return this.persistTerminal(active, {
335
+ return this.terminal(active, {
401
336
  status: 'review-ready', pullRequestUrl: pullRequest.url, continuationEpoch: commitSha,
402
- }, 'review-ready', false, {
337
+ }, 'review-ready', {
403
338
  reviewFeedback: initializeReviewFeedback(active.record.reviewFeedback ?? {
404
339
  version: 1, updateEpoch: 0, consumedSourceIds: [], previousPublishedHeadSha: null,
405
340
  repairRound: 0, activeBatch: null, history: [], verifiedReceipt: null,
@@ -484,7 +419,7 @@ export class RunIssue {
484
419
  || pendingEffect.message !== message || pendingEffect.candidateRef !== candidateBinding.candidateRef) {
485
420
  return this.persistRetainedCommitIntentTerminal(active, 'review-feedback-candidate-pendingEffect-diverged');
486
421
  }
487
- let authorizationFailure;
422
+ let authorizationValidation;
488
423
  const settlement = await settleCommitEffect(pendingEffect, {
489
424
  observe: async () => {
490
425
  const observed = await candidate.createOrObserveCommit({
@@ -500,9 +435,8 @@ export class RunIssue {
500
435
  authorize: async () => {
501
436
  if (!await this.authorized(active, config))
502
437
  return false;
503
- const validation = await coordinator.revalidate({ batch, epoch: 'pre-update', expectedHeadSha: oldHead });
504
- authorizationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-precommit-revalidation-failed');
505
- return !authorizationFailure;
438
+ authorizationValidation = await coordinator.revalidate({ batch, issueNumber, epoch: 'pre-update', expectedHeadSha: oldHead });
439
+ return authorizationValidation.status === 'valid';
506
440
  },
507
441
  invoke: async () => {
508
442
  const invoked = await candidate.createOrObserveCommit({
@@ -515,8 +449,16 @@ export class RunIssue {
515
449
  },
516
450
  });
517
451
  if (settlement.status === 'unauthorized') {
518
- return authorizationFailure
519
- ?? this.persistRetainedCommitIntentTerminal(active, 'review-feedback-publication-authority-revoked');
452
+ if (authorizationValidation?.status === 'retryable') {
453
+ return this.invokedFailure(active, 'review-feedback-precommit-revalidation-failed-retryable');
454
+ }
455
+ active = await this.confirmEffect(active);
456
+ if (authorizationValidation) {
457
+ const failure = await this.mapFeedbackRevalidation(active, authorizationValidation, 'review-feedback-precommit-revalidation-failed');
458
+ if (failure)
459
+ return failure;
460
+ }
461
+ return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
520
462
  }
521
463
  if (settlement.status === 'unknown') {
522
464
  return this.invokedFailure(active, 'candidate-ref-update-unknown', 'Candidate update outcome is unknown; retain and observe the exact commit effect.');
@@ -551,7 +493,7 @@ export class RunIssue {
551
493
  || pendingEffect.parentSha !== oldHead || pendingEffect.message !== message) {
552
494
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-commit-pendingEffect-diverged');
553
495
  }
554
- let authorizationFailure;
496
+ let authorizationValidation;
555
497
  const settlement = await settleCommitEffect(pendingEffect, {
556
498
  observe: async () => {
557
499
  const observed = await this.dependencies.git.inspectHead(active.record.worktreePath);
@@ -566,20 +508,30 @@ export class RunIssue {
566
508
  authorize: async () => {
567
509
  if (!await this.authorized(active, config))
568
510
  return false;
569
- const validation = await coordinator.revalidate({ batch, epoch: 'pre-update', expectedHeadSha: oldHead });
570
- authorizationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-precommit-revalidation-failed');
571
- return !authorizationFailure;
511
+ authorizationValidation = await coordinator.revalidate({ batch, issueNumber, epoch: 'pre-update', expectedHeadSha: oldHead });
512
+ return authorizationValidation.status === 'valid';
572
513
  },
573
514
  invoke: async () => {
574
515
  await this.dependencies.git.commit({ worktreePath: active.record.worktreePath, message: pendingEffect.message });
575
516
  },
576
517
  });
577
518
  if (settlement.status === 'unauthorized') {
578
- return authorizationFailure
579
- ?? this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
519
+ if (authorizationValidation?.status === 'retryable') {
520
+ return this.invokedFailure(active, 'review-feedback-precommit-revalidation-failed-retryable');
521
+ }
522
+ active = await this.confirmEffect(active);
523
+ if (authorizationValidation) {
524
+ const failure = await this.mapFeedbackRevalidation(active, authorizationValidation, 'review-feedback-precommit-revalidation-failed');
525
+ if (failure)
526
+ return failure;
527
+ }
528
+ return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
580
529
  }
581
530
  if (settlement.status === 'unknown')
582
531
  return this.invokedFailure(active, 'review-feedback-commit-delivery-unknown');
532
+ if (settlement.status === 'diverged') {
533
+ return this.blockReviewFeedback(await this.confirmEffect(active), 'safety', 'review-feedback-commit-observation-diverged');
534
+ }
583
535
  if (settlement.status !== 'confirmed') {
584
536
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-commit-observation-diverged');
585
537
  }
@@ -608,7 +560,7 @@ export class RunIssue {
608
560
  || pendingEffect.treeSha !== commit.treeSha || pendingEffect.branch !== active.record.branchName || pendingEffect.priorRemoteSha !== oldHead) {
609
561
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-push-pendingEffect-diverged');
610
562
  }
611
- let authorizationFailure;
563
+ let authorizationValidation;
612
564
  const settlement = await settlePushEffect(pendingEffect, {
613
565
  observe: async () => {
614
566
  remote = await this.dependencies.git.getRemoteBranchSha(active.record.worktreePath, active.record.branchName);
@@ -617,9 +569,8 @@ export class RunIssue {
617
569
  authorize: async () => {
618
570
  if (!await this.authorized(active, config))
619
571
  return false;
620
- const validation = await coordinator.revalidate({ batch, epoch: 'pre-update', expectedHeadSha: oldHead });
621
- authorizationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-prepush-revalidation-failed');
622
- return !authorizationFailure;
572
+ authorizationValidation = await coordinator.revalidate({ batch, issueNumber, epoch: 'pre-update', expectedHeadSha: oldHead });
573
+ return authorizationValidation.status === 'valid';
623
574
  },
624
575
  invoke: () => this.dependencies.git.push({
625
576
  worktreePath: active.record.worktreePath,
@@ -627,16 +578,28 @@ export class RunIssue {
627
578
  }),
628
579
  });
629
580
  if (settlement.status === 'unauthorized') {
630
- return authorizationFailure
631
- ?? this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
581
+ if (authorizationValidation?.status === 'retryable') {
582
+ return this.invokedFailure(active, 'review-feedback-prepush-revalidation-failed-retryable');
583
+ }
584
+ active = await this.confirmEffect(active);
585
+ if (authorizationValidation) {
586
+ const failure = await this.mapFeedbackRevalidation(active, authorizationValidation, 'review-feedback-prepush-revalidation-failed');
587
+ if (failure)
588
+ return failure;
589
+ }
590
+ return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
632
591
  }
633
592
  if (settlement.status === 'unknown')
634
593
  return this.invokedFailure(active, 'review-feedback-push-delivery-unknown');
594
+ if (settlement.status === 'diverged') {
595
+ return this.blockReviewFeedback(await this.confirmEffect(active), 'safety', 'review-feedback-push-observation-diverged');
596
+ }
635
597
  if (settlement.status !== 'confirmed') {
636
598
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-push-observation-diverged');
637
599
  }
600
+ active = await this.confirmEffect(active);
638
601
  }
639
- const postPush = await coordinator.revalidate({ batch, epoch: 'post-push', expectedHeadSha: head });
602
+ const postPush = await coordinator.revalidate({ batch, issueNumber, epoch: 'post-push', expectedHeadSha: head });
640
603
  const postPushFailure = await this.mapFeedbackRevalidation(active, postPush, 'review-feedback-postpush-revalidation-failed');
641
604
  if (postPushFailure)
642
605
  return postPushFailure;
@@ -668,10 +631,7 @@ export class RunIssue {
668
631
  || pendingEffect.marker !== marker || pendingEffect.bodySha256 !== sha256(body) || pendingEffect.epochHeadSha !== head) {
669
632
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-summary-pendingEffect-diverged');
670
633
  }
671
- const validation = await coordinator.revalidate({ batch, epoch: 'post-push', expectedHeadSha: head });
672
- const validationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-summary-revalidation-failed');
673
- if (validationFailure)
674
- return validationFailure;
634
+ let authorizationValidation;
675
635
  let matches = [];
676
636
  const settlement = await settleCommentEffect(pendingEffect, {
677
637
  observe: async () => {
@@ -681,16 +641,33 @@ export class RunIssue {
681
641
  return 'absent';
682
642
  return matches.length === 1 && matches[0].body === body ? 'confirmed' : 'diverged';
683
643
  },
684
- authorize: () => this.authorized(active, config),
644
+ authorize: async () => {
645
+ if (!await this.authorized(active, config))
646
+ return false;
647
+ authorizationValidation = await coordinator.revalidate({ batch, issueNumber, epoch: 'post-push', expectedHeadSha: head });
648
+ return authorizationValidation.status === 'valid';
649
+ },
685
650
  invoke: async () => {
686
651
  await this.dependencies.pullRequests.postConversationComment(batch.pullRequest.number, body);
687
652
  },
688
653
  });
689
654
  if (settlement.status === 'unauthorized') {
655
+ if (authorizationValidation?.status === 'retryable') {
656
+ return this.invokedFailure(active, 'review-feedback-summary-revalidation-failed-retryable');
657
+ }
658
+ active = await this.confirmEffect(active);
659
+ if (authorizationValidation) {
660
+ const failure = await this.mapFeedbackRevalidation(active, authorizationValidation, 'review-feedback-summary-revalidation-failed');
661
+ if (failure)
662
+ return failure;
663
+ }
690
664
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
691
665
  }
692
666
  if (settlement.status === 'unknown')
693
667
  return this.invokedFailure(active, 'review-feedback-summary-delivery-unknown');
668
+ if (settlement.status === 'diverged') {
669
+ return this.blockReviewFeedback(await this.confirmEffect(active), 'safety', 'review-feedback-summary-observation-diverged');
670
+ }
694
671
  if (settlement.status !== 'confirmed') {
695
672
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-summary-observation-diverged');
696
673
  }
@@ -723,23 +700,37 @@ export class RunIssue {
723
700
  || !sameStrings(pendingEffect.expected, finalLabels)) {
724
701
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-final-labels-pendingEffect-diverged');
725
702
  }
726
- const validation = await coordinator.revalidate({ batch, epoch: 'post-push', expectedHeadSha: head });
727
- const validationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-final-labels-revalidation-failed');
728
- if (validationFailure)
729
- return validationFailure;
703
+ let authorizationValidation;
730
704
  const settlement = await settleLabelsEffect(pendingEffect, {
731
705
  observe: async () => {
732
706
  const issue = await this.readIssue(issueNumber);
733
- return !issue ? 'diverged' : sameStrings(issue.labels, finalLabels) ? 'confirmed' : 'absent';
707
+ return !issue ? 'diverged' : sameStrings(managedLabelProjection(issue.labels, config), finalLabels) ? 'confirmed' : 'absent';
708
+ },
709
+ authorize: async () => {
710
+ if (!await this.authorized(active, config))
711
+ return false;
712
+ authorizationValidation = await coordinator.revalidate({ batch, issueNumber, epoch: 'post-push', expectedHeadSha: head });
713
+ return authorizationValidation.status === 'valid';
734
714
  },
735
- authorize: () => this.authorized(active, config),
736
715
  invoke: () => this.dependencies.issues.setLabels(issueNumber, finalLabels),
737
716
  });
738
717
  if (settlement.status === 'unauthorized') {
718
+ if (authorizationValidation?.status === 'retryable') {
719
+ return this.invokedFailure(active, 'review-feedback-final-labels-revalidation-failed-retryable');
720
+ }
721
+ active = await this.confirmEffect(active);
722
+ if (authorizationValidation) {
723
+ const failure = await this.mapFeedbackRevalidation(active, authorizationValidation, 'review-feedback-final-labels-revalidation-failed');
724
+ if (failure)
725
+ return failure;
726
+ }
739
727
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-publication-authority-revoked');
740
728
  }
741
729
  if (settlement.status === 'unknown')
742
730
  return this.invokedFailure(active, 'review-feedback-final-labels-delivery-unknown');
731
+ if (settlement.status === 'diverged') {
732
+ return this.blockReviewFeedback(await this.confirmEffect(active), 'safety', 'review-feedback-final-labels-diverged');
733
+ }
743
734
  if (settlement.status !== 'confirmed') {
744
735
  return this.blockReviewFeedback(active, 'safety', 'review-feedback-final-labels-diverged');
745
736
  }
@@ -755,10 +746,17 @@ export class RunIssue {
755
746
  summaryCommentId: summaryId,
756
747
  publishedAt: this.timestamp(),
757
748
  };
758
- const pullRequestUrl = `https://github.com/${active.record.canonicalRepository}/pull/${batch.pullRequest.number}`;
759
- return this.persistTerminal(active, {
760
- status: 'review-ready', pullRequestUrl, continuationEpoch: head,
761
- }, 'review-ready', false, {
749
+ const pullRequest = await this.dependencies.pullRequests.findOpen({
750
+ headBranch: active.record.branchName,
751
+ baseBranch: config.github.baseBranch,
752
+ });
753
+ if (!pullRequest || pullRequest.number !== batch.pullRequest.number || pullRequest.nodeId !== batch.pullRequest.nodeId
754
+ || pullRequest.headSha !== head) {
755
+ return this.blockReviewFeedback(active, 'safety', 'review-feedback-published-pr-identity-diverged');
756
+ }
757
+ return this.terminal(active, {
758
+ status: 'review-ready', pullRequestUrl: pullRequest.url, continuationEpoch: head,
759
+ }, 'review-ready', {
762
760
  reviewFeedback: publishReviewFeedback(active.record.reviewFeedback, receipt),
763
761
  });
764
762
  }
@@ -774,13 +772,16 @@ export class RunIssue {
774
772
  baseBranch: config.github.baseBranch,
775
773
  });
776
774
  if (!pullRequest?.number || !pullRequest.nodeId || !pullRequest.headSha) {
777
- return { result: await this.persistTerminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-pr-identity-missing', false) };
775
+ return { result: await this.terminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-pr-identity-missing') };
776
+ }
777
+ if (pullRequest.url !== terminal.pullRequestUrl) {
778
+ return { result: await this.terminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-prior-handoff-identity-mismatch') };
778
779
  }
779
780
  const expectedHeadSha = feedback.previousPublishedHeadSha === null
780
781
  ? pullRequest.headSha
781
782
  : feedback.previousPublishedHeadSha;
782
783
  if (!expectedHeadSha) {
783
- return { result: await this.persistTerminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-head-missing', false) };
784
+ return { result: await this.terminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-head-missing') };
784
785
  }
785
786
  const observed = await this.dependencies.reviewFeedback.observeAndFreeze({
786
787
  runId: starting.record.runId,
@@ -797,29 +798,40 @@ export class RunIssue {
797
798
  headSha: pullRequest.headSha,
798
799
  body: pullRequest.body,
799
800
  },
801
+ ...(starting.record.terminalNotifications ? {
802
+ issueCommentCutoff: {
803
+ issueNumber: starting.record.issueNumber,
804
+ ...starting.record.terminalNotifications.commentCutoff,
805
+ },
806
+ } : {}),
800
807
  });
801
808
  if (observed.status === 'retryable')
802
809
  return { result: await this.invokedFailure(starting, 'review-feedback-observation-retryable') };
803
810
  if (observed.status === 'blocked') {
804
- return { result: await this.persistTerminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-observation-blocked', false) };
811
+ return { result: await this.terminal(starting, { status: 'blocked', kind: 'safety', resumable: false }, 'review-feedback-observation-blocked') };
805
812
  }
813
+ let ready = starting;
806
814
  if (feedback.previousPublishedHeadSha === null) {
807
- const sourceIds = observed.status === 'frozen'
815
+ const issueCommentBatch = observed.status === 'frozen'
816
+ && observed.batch.sources.every((source) => source.kind === 'issue-comment');
817
+ const sourceIds = observed.status === 'frozen' && !issueCommentBatch
808
818
  ? observed.batch.sources.map((source) => source.sourceId)
809
- : observed.eligibleSourceIds;
810
- const active = await this.persist(starting, {
819
+ : observed.status === 'none' ? observed.eligibleSourceIds : [];
820
+ ready = await this.persist(starting, {
811
821
  reviewFeedback: initializeReviewFeedback(feedback, expectedHeadSha, sourceIds),
812
822
  });
813
- return { result: publicOutcome(active.record.terminalOutcome) };
823
+ if (!issueCommentBatch)
824
+ return { result: publicOutcome(ready.record.terminalOutcome) };
814
825
  }
815
- if (feedback.activeBatch || observed.status === 'none')
826
+ const readyFeedback = ready.record.reviewFeedback;
827
+ if (readyFeedback.activeBatch || observed.status === 'none')
816
828
  return { result: publicOutcome(terminal) };
817
829
  if (!await this.authorizedForExactLabels(starting, [config.github.labels.review.name])) {
818
830
  return { result: publicOutcome(terminal) };
819
831
  }
820
- const projected = projectReviewFeedbackBatch(observed.batch, starting.record.directReview.targetRevision);
821
- const candidateTreeSha = await this.dependencies.git.getTreeSha(starting.record.worktreePath);
822
- const activation = projectValidationFeedbackActivation(starting.record, {
832
+ const projected = projectReviewFeedbackBatch(observed.batch, ready.record.directReview.targetRevision);
833
+ const candidateTreeSha = await this.dependencies.git.getTreeSha(ready.record.worktreePath);
834
+ const activation = projectValidationFeedbackActivation(ready.record, {
823
835
  batch: observed.batch,
824
836
  repairFindings: projected.repairFindings,
825
837
  candidateTreeSha,
@@ -830,7 +842,16 @@ export class RunIssue {
830
842
  expected: sortedUnique([config.github.labels.auto.name, config.github.labels.running.name]),
831
843
  },
832
844
  });
833
- const active = await this.persistValidationTransition(starting, activation);
845
+ if (observed.batch.sources.every((source) => source.kind === 'issue-comment')) {
846
+ activation.changes.cycle = ready.record.cycle + 1;
847
+ activation.changes.checks = structuredClone(ready.record.checks);
848
+ activation.changes.checkedChangeSha256 = ready.record.checkedChangeSha256;
849
+ activation.changes.proofId = ready.record.proofId;
850
+ activation.changes.proofReceipt = structuredClone(ready.record.proofReceipt);
851
+ activation.changes.implementationResult = structuredClone(ready.record.implementationResult);
852
+ activation.changes.terminalNotifications = undefined;
853
+ }
854
+ const active = await this.persistValidationTransition(ready, activation);
834
855
  return this.resumeActivatedReviewFeedback(active, targetRoot, config);
835
856
  }
836
857
  async resumeActivatedReviewFeedback(starting, targetRoot, config) {
@@ -844,41 +865,45 @@ export class RunIssue {
844
865
  const runningLabels = sortedUnique([config.github.labels.auto.name, config.github.labels.running.name]);
845
866
  const reviewLabels = [config.github.labels.review.name];
846
867
  const pendingEffect = active.record.pendingEffect;
847
- if (pendingEffect && (pendingEffect.kind !== 'review-activation-labels'
848
- || pendingEffect.issueNumber !== active.record.issueNumber
849
- || pendingEffect.batchId !== batch.batchId
850
- || !sameStrings(pendingEffect.expected, runningLabels))) {
868
+ const activationEffect = pendingEffect?.kind === 'review-activation-labels' ? pendingEffect : undefined;
869
+ if (pendingEffect && pendingEffect.kind !== 'continuation-worktree-create' && (!activationEffect
870
+ || activationEffect.issueNumber !== active.record.issueNumber
871
+ || activationEffect.batchId !== batch.batchId
872
+ || !sameStrings(activationEffect.expected, runningLabels))) {
851
873
  return { result: await this.blockReviewFeedback(active, 'safety', 'review-feedback-activation-pendingEffect-diverged') };
852
874
  }
853
875
  const issue = await this.readIssue(active.record.issueNumber);
854
- const labelsAreRunning = !!issue && issue.state === 'OPEN' && sameStrings(issue.labels, runningLabels)
876
+ const labelsAreRunning = !!issue && issue.state === 'OPEN' && sameStrings(managedLabelProjection(issue.labels, config), runningLabels)
855
877
  && this.hasTrustedClaim(issue, active.record);
856
- const labelsAreReview = !!issue && issue.state === 'OPEN' && sameStrings(issue.labels, reviewLabels)
878
+ const labelsAreReview = !!issue && issue.state === 'OPEN' && sameStrings(managedLabelProjection(issue.labels, config), reviewLabels)
857
879
  && this.hasTrustedClaim(issue, active.record);
858
880
  if (!labelsAreRunning && !labelsAreReview) {
859
881
  return { result: await this.blockReviewFeedback(active, 'safety', 'review-feedback-activation-authority-revoked') };
860
882
  }
861
883
  const validation = await this.dependencies.reviewFeedback.revalidate({
862
- batch, epoch: 'pre-update', expectedHeadSha,
884
+ batch, issueNumber: active.record.issueNumber, epoch: 'pre-update', expectedHeadSha,
863
885
  });
864
886
  const validationFailure = await this.mapFeedbackRevalidation(active, validation, 'review-feedback-activation-revalidation-failed');
865
887
  if (validationFailure)
866
888
  return { result: validationFailure };
867
- if (pendingEffect) {
868
- const settlement = await settleLabelsEffect(pendingEffect, {
889
+ if (activationEffect) {
890
+ const settlement = await settleLabelsEffect(activationEffect, {
869
891
  observe: async () => {
870
892
  const observed = await this.readIssue(active.record.issueNumber);
871
893
  if (!observed || !this.hasTrustedClaim(observed, active.record))
872
894
  return 'diverged';
873
- if (sameStrings(observed.labels, runningLabels))
895
+ if (sameStrings(managedLabelProjection(observed.labels, config), runningLabels))
874
896
  return 'confirmed';
875
- return sameStrings(observed.labels, reviewLabels) ? 'absent' : 'diverged';
897
+ return sameStrings(managedLabelProjection(observed.labels, config), reviewLabels) ? 'absent' : 'diverged';
876
898
  },
877
899
  invoke: () => this.dependencies.issues.setLabels(active.record.issueNumber, runningLabels),
878
900
  });
879
901
  if (settlement.status === 'unknown') {
880
902
  return { result: await this.invokedFailure(active, 'review-feedback-activation-labels-delivery-unknown') };
881
903
  }
904
+ if (settlement.status === 'diverged') {
905
+ return { result: await this.blockReviewFeedback(await this.confirmEffect(active), 'safety', 'review-feedback-activation-labels-diverged') };
906
+ }
882
907
  if (settlement.status !== 'confirmed') {
883
908
  return { result: await this.blockReviewFeedback(active, 'safety', 'review-feedback-activation-labels-diverged') };
884
909
  }
@@ -998,26 +1023,17 @@ export class RunIssue {
998
1023
  if (active.record.pendingEffect?.kind === 'outcome-evidence') {
999
1024
  if (active.record.terminalOutcome) {
1000
1025
  const observed = await this.observeOutcomeEvidenceEffect(active, active.record.pendingEffect.code, active.record.pendingEffect.summary);
1001
- await this.confirmEffect(observed.active);
1026
+ active = await this.confirmEffect(observed.active);
1027
+ active = await this.settleTerminalNotificationsBestEffort(active);
1002
1028
  return publicOutcome(active.record.terminalOutcome);
1003
1029
  }
1004
1030
  const seed = parseTerminalSeedSummary(active.record.pendingEffect.summary);
1005
- return await this.persistTerminal(active, seed, active.record.pendingEffect.code, false);
1031
+ return await this.terminal(active, seed, active.record.pendingEffect.code);
1006
1032
  }
1007
- if (active.record.pendingEffect?.kind === 'review-blocked-labels') {
1008
- return await this.blockReviewFeedback(active, active.record.pendingEffect.blockKind, active.record.pendingEffect.evidenceCode);
1009
- }
1010
- if (active.record.pendingEffect?.kind === 'blocked-labels') {
1011
- return await this.publishBlockedTerminal(active, {
1012
- status: 'blocked',
1013
- kind: active.record.pendingEffect.blockKind,
1014
- resumable: active.record.pendingEffect.resumable,
1015
- }, active.record.pendingEffect.evidenceCode);
1016
- }
1017
- if (existing.terminalOutcome?.status === 'blocked'
1018
- && issue?.state === 'OPEN'
1019
- && blockedLabelProjection(issue.labels, config).status !== 'settled') {
1020
- return await this.reconcilePersistedBlockedTerminal(active, issue, existing.terminalOutcome);
1033
+ if (active.record.terminalOutcome && (active.record.terminalNotifications
1034
+ || active.record.pendingEffect?.kind === 'terminal-comment'
1035
+ || active.record.pendingEffect?.kind === 'terminal-labels')) {
1036
+ active = await this.settleTerminalNotificationsBestEffort(active);
1021
1037
  }
1022
1038
  if (active.record.terminalOutcome?.status === 'transport-failed'
1023
1039
  && !active.record.terminalOutcome.resumable
@@ -1072,7 +1088,9 @@ export class RunIssue {
1072
1088
  return reconciled;
1073
1089
  active = reconciled.active;
1074
1090
  }
1075
- if (active.record.reviewFeedback?.activeBatch && active.record.pendingEffect?.kind === 'review-activation-labels') {
1091
+ if (active.record.reviewFeedback?.activeBatch
1092
+ && (active.record.pendingEffect?.kind === 'review-activation-labels'
1093
+ || active.record.pendingEffect?.kind === 'continuation-worktree-create')) {
1076
1094
  const activation = await this.resumeActivatedReviewFeedback(active, targetRoot, config);
1077
1095
  if ('result' in activation)
1078
1096
  return activation.result;
@@ -1193,7 +1211,7 @@ export class RunIssue {
1193
1211
  }
1194
1212
  attemptLoop: while (true) {
1195
1213
  if (!await this.authorized(active, config)) {
1196
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
1214
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'authorization-revoked');
1197
1215
  }
1198
1216
  if (this.signal.aborted)
1199
1217
  return await this.terminal(active, { status: 'cancelled' });
@@ -1224,6 +1242,12 @@ export class RunIssue {
1224
1242
  ...(feedbackBatch ? {
1225
1243
  reviewFeedbackRound: active.record.reviewFeedback.repairRound,
1226
1244
  reviewFeedback: feedbackProjection.workerFeedback,
1245
+ reviewFeedbackPullRequest: {
1246
+ number: feedbackBatch.pullRequest.number,
1247
+ headSha: feedbackBatch.pullRequest.headSha,
1248
+ headRefName: feedbackBatch.pullRequest.headRefName,
1249
+ url: `https://github.com/${active.record.canonicalRepository}/pull/${feedbackBatch.pullRequest.number}`,
1250
+ },
1227
1251
  } : {}),
1228
1252
  onPrepared: async (prepared) => {
1229
1253
  const currentActive = active;
@@ -1327,8 +1351,11 @@ export class RunIssue {
1327
1351
  }
1328
1352
  return await this.terminal(active, implementationBlockerOutcome(report.blocker));
1329
1353
  }
1354
+ if (report.status === 'answer-only' || report.status === 'boundary') {
1355
+ return this.completeIssueFeedbackResponse(active, report, config);
1356
+ }
1330
1357
  if (await this.dependencies.git.getHead(worktreePath) !== expectedImplementationHead(active.record)) {
1331
- return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
1358
+ return await this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'implementation-head-drift');
1332
1359
  }
1333
1360
  const changedFiles = await this.dependencies.git.listChangedFiles(worktreePath);
1334
1361
  if (changedFiles.length === 0 || !sameStrings(changedFiles, report.changedFiles)) {
@@ -1348,6 +1375,7 @@ export class RunIssue {
1348
1375
  active = await this.adoptAttempt(active, sha256(canonicalJson(report)), {
1349
1376
  transportRetries: active.record.transportRetries,
1350
1377
  reportRepairs: 0,
1378
+ implementationResult: { summary: report.summary, residualRisks: report.residualRisks },
1351
1379
  });
1352
1380
  active = await this.clearAttempt(active);
1353
1381
  }
@@ -1880,7 +1908,10 @@ export class RunIssue {
1880
1908
  const record = { ...active.record, ...normalized, updatedAt: this.timestamp() };
1881
1909
  if (Object.hasOwn(changes, 'pendingEffect') && changes.pendingEffect === undefined)
1882
1910
  delete record.pendingEffect;
1883
- for (const key of ['checkedChangeSha256', 'proofId', 'proofReceipt', 'terminalOutcome', 'outcomeEvidenceId', 'reviewFeedback', 'changeBindingVersion', 'candidateBinding', 'candidateMaterialization', 'activeAttempt']) {
1911
+ if (Object.hasOwn(changes, 'terminalOutcome') && changes.terminalOutcome === undefined
1912
+ && !Object.hasOwn(changes, 'terminalNotifications'))
1913
+ delete record.terminalNotifications;
1914
+ for (const key of ['checkedChangeSha256', 'proofId', 'proofReceipt', 'terminalOutcome', 'outcomeEvidenceId', 'reviewFeedback', 'changeBindingVersion', 'candidateBinding', 'candidateMaterialization', 'activeAttempt', 'terminalNotifications']) {
1884
1915
  if (Object.hasOwn(changes, key) && changes[key] === undefined)
1885
1916
  delete record[key];
1886
1917
  }
@@ -1974,7 +2005,7 @@ export class RunIssue {
1974
2005
  }
1975
2006
  async authorizedForExactLabels(active, expectedLabels) {
1976
2007
  const issue = await this.readIssue(active.record.issueNumber);
1977
- return !!issue && issue.state === 'OPEN' && sameStrings(issue.labels, expectedLabels)
2008
+ return !!issue && issue.state === 'OPEN' && sameStrings(managedLabelProjection(issue.labels, active.config), expectedLabels)
1978
2009
  && this.hasTrustedClaim(issue, active.record);
1979
2010
  }
1980
2011
  hasTrustedClaim(issue, record) {
@@ -2030,6 +2061,7 @@ export class RunIssue {
2030
2061
  }
2031
2062
  const validation = await this.dependencies.reviewFeedback.revalidate({
2032
2063
  batch,
2064
+ issueNumber,
2033
2065
  epoch: 'pre-update',
2034
2066
  expectedHeadSha: batch.priorPublishedHeadSha,
2035
2067
  });
@@ -2392,7 +2424,7 @@ export class RunIssue {
2392
2424
  : this.persistCandidateEvidenceSafetyTerminal(active, 'candidate-execution-missing');
2393
2425
  }
2394
2426
  if (inspection.value === 'mutated') {
2395
- return this.persistTerminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-materialization-mutated', false);
2427
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'candidate-materialization-mutated');
2396
2428
  }
2397
2429
  if (active.record.activeAttempt?.stage === 'observed' && active.record.activeAttempt.result) {
2398
2430
  return { active };
@@ -2484,7 +2516,7 @@ export class RunIssue {
2484
2516
  reviewFeedback: blockReviewFeedback(active.record.reviewFeedback, 'safety', this.timestamp()),
2485
2517
  });
2486
2518
  }
2487
- return this.persistTerminal(active, { status: 'blocked', kind: 'safety', resumable: false }, evidenceCode, false);
2519
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, evidenceCode);
2488
2520
  }
2489
2521
  mapCandidateFailure(active, code) {
2490
2522
  if (code === 'candidate-unstable' || code === 'candidate-io-failed' || code === 'candidate-materialization-io-failed') {
@@ -2857,54 +2889,7 @@ export class RunIssue {
2857
2889
  return transitioned;
2858
2890
  }
2859
2891
  async blockReviewFeedback(active, kind, evidenceCode, outcome = { status: 'blocked', kind, resumable: false }) {
2860
- const feedback = active.record.reviewFeedback;
2861
- if (!feedback?.activeBatch)
2862
- return this.terminal(active, { status: 'blocked', kind, resumable: false }, evidenceCode);
2863
- {
2864
- const config = active.config;
2865
- const blockedLabels = [config.github.labels.auto.name, config.github.labels.blocked.name].sort();
2866
- const existingIntent = active.record.pendingEffect;
2867
- if (existingIntent?.kind !== 'review-blocked-labels') {
2868
- active = await this.persist(active, { pendingEffect: {
2869
- kind: 'review-blocked-labels', issueNumber: active.record.issueNumber,
2870
- batchId: feedback.activeBatch.batchId, expected: blockedLabels, blockKind: kind, evidenceCode,
2871
- } });
2872
- }
2873
- else if (existingIntent.issueNumber !== active.record.issueNumber
2874
- || existingIntent.batchId !== feedback.activeBatch.batchId
2875
- || !sameStrings(existingIntent.expected, blockedLabels)
2876
- || existingIntent.blockKind !== kind
2877
- || existingIntent.evidenceCode !== evidenceCode) {
2878
- return this.invokedFailure(active, 'review-feedback-blocked-labels-pendingEffect-diverged');
2879
- }
2880
- const ownedRunningLabels = [config.github.labels.auto.name, config.github.labels.running.name].sort();
2881
- const ownedReviewLabels = [config.github.labels.review.name];
2882
- const effect = active.record.pendingEffect;
2883
- if (effect?.kind !== 'review-blocked-labels') {
2884
- return this.invokedFailure(active, 'review-feedback-blocked-labels-pendingEffect-diverged');
2885
- }
2886
- const settlement = await settleLabelsEffect(effect, {
2887
- observe: async () => {
2888
- const issue = await this.readIssue(active.record.issueNumber);
2889
- if (!issue || issue.state !== 'OPEN')
2890
- return 'diverged';
2891
- if (sameStrings(issue.labels, blockedLabels))
2892
- return 'confirmed';
2893
- return sameStrings(issue.labels, ownedRunningLabels) || sameStrings(issue.labels, ownedReviewLabels)
2894
- ? 'absent' : 'diverged';
2895
- },
2896
- invoke: () => this.dependencies.issues.setLabels(active.record.issueNumber, blockedLabels),
2897
- });
2898
- if (settlement.status === 'unknown') {
2899
- return this.invokedFailure(active, 'review-feedback-blocked-labels-delivery-unknown');
2900
- }
2901
- if (settlement.status !== 'confirmed') {
2902
- return this.invokedFailure(active, 'review-feedback-blocked-labels-observation-diverged');
2903
- }
2904
- }
2905
- return this.persistTerminal(active, outcome, evidenceCode, false, {
2906
- reviewFeedback: blockReviewFeedback(feedback, kind, this.timestamp()),
2907
- }, true);
2892
+ return this.terminal(active, outcome, evidenceCode);
2908
2893
  }
2909
2894
  async mapImplementationFailure(active, result) {
2910
2895
  if (result.kind === 'transport-failed')
@@ -2937,7 +2922,7 @@ export class RunIssue {
2937
2922
  async revoked(active) {
2938
2923
  if (active.record.pendingEffect)
2939
2924
  return this.invokedFailure(active, 'revoked-with-pending-effect');
2940
- return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true });
2925
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: true }, 'authorization-revoked');
2941
2926
  }
2942
2927
  async invokedFailure(active, code, summary = 'Publication delivery requires reconciliation.') {
2943
2928
  const evidence = await this.dependencies.writeEvidence({ runId: active.record.runId, code, summary });
@@ -2951,8 +2936,8 @@ export class RunIssue {
2951
2936
  });
2952
2937
  return { status: 'repair-ready', source, blockerIds: [...new Set(blockerIds)].sort(), evidencePath: evidence.path };
2953
2938
  }
2954
- async observeOutcomeEvidenceEffect(active, code, summary, replaceConfirmedEffect = false) {
2955
- if (!active.record.pendingEffect || replaceConfirmedEffect) {
2939
+ async observeOutcomeEvidenceEffect(active, code, summary) {
2940
+ if (!active.record.pendingEffect) {
2956
2941
  const recordedAt = this.timestamp();
2957
2942
  const path = this.dependencies.outcomeEvidencePath(active.record.runId, code, sha256(summary));
2958
2943
  const bytes = outcomeEvidenceBytes({ runId: active.record.runId, code, summary, recordedAt });
@@ -2983,115 +2968,311 @@ export class RunIssue {
2983
2968
  throw new PostEffectStateError(active);
2984
2969
  return { active, path: effect.path, id: `evidence:${effect.runId}:${effect.code}` };
2985
2970
  }
2986
- async terminal(active, outcome, evidenceCode = outcome.status, retainIntent = false) {
2987
- if (active.record.reviewFeedback?.activeBatch && outcome.status === 'blocked') {
2988
- return this.blockReviewFeedback(active, outcome.kind, evidenceCode, outcome);
2971
+ async completeIssueFeedbackResponse(starting, report, config) {
2972
+ let active = starting;
2973
+ const feedback = active.record.reviewFeedback;
2974
+ const batch = feedback?.activeBatch;
2975
+ if (!feedback || !batch || batch.sources.some((source) => source.kind !== 'issue-comment')) {
2976
+ return this.terminal(active, { status: 'internal-error', code: 'issue-feedback-response-source-invalid' });
2989
2977
  }
2990
- if (outcome.status === 'blocked') {
2991
- return this.publishBlockedTerminal(active, outcome, evidenceCode);
2978
+ if (await this.dependencies.git.getHead(active.record.worktreePath) !== batch.priorPublishedHeadSha
2979
+ || (await this.dependencies.git.listChangedFilesIgnoringUntrackedRoot(active.record.worktreePath, config.proof.artifactDir)).length !== 0) {
2980
+ return this.terminal(active, { status: 'internal-error', code: 'issue-feedback-response-mutated-worktree' });
2992
2981
  }
2993
- return this.persistTerminal(active, outcome, evidenceCode, retainIntent);
2994
- }
2995
- async publishBlockedTerminal(starting, outcome, evidenceCode) {
2996
- let active = starting;
2997
- const autoBlocked = [active.config.github.labels.auto.name, active.config.github.labels.blocked.name].sort();
2998
- const blockedOnly = [active.config.github.labels.blocked.name];
2999
- let expected = autoBlocked;
3000
- const existing = active.record.pendingEffect;
3001
- if (!existing) {
3002
- active = await this.persist(active, { pendingEffect: {
3003
- kind: 'blocked-labels',
3004
- issueNumber: active.record.issueNumber,
3005
- expected,
3006
- blockKind: outcome.kind,
3007
- resumable: outcome.resumable,
3008
- evidenceCode,
3009
- } });
2982
+ const marker = `<!-- codex-orchestrator:run:${active.record.runId}:issue-feedback:${batch.batchId} -->`;
2983
+ const response = publicIssueFeedbackText(report.response, report.status === 'boundary'
2984
+ ? 'This request cannot be acted on without a new issue-local decision or authority.'
2985
+ : 'The requested answer could not be published safely.');
2986
+ const body = [
2987
+ marker,
2988
+ report.status === 'boundary' ? 'codex-orchestrator reached an authority boundary.' : 'codex-orchestrator answered this follow-up.',
2989
+ '',
2990
+ response,
2991
+ ...(report.status === 'boundary' ? ['', `Boundary: ${report.boundary.kind}`] : []),
2992
+ ].join('\n');
2993
+ const directReview = active.record.directReview;
2994
+ const sourceIds = batch.sources.map((source) => source.sourceId);
2995
+ const restoredDirectReview = {
2996
+ ...structuredClone(directReview),
2997
+ status: 'clear',
2998
+ stage: 'review',
2999
+ review: { ...structuredClone(directReview.review), disposition: 'clear' },
3000
+ repairFindings: directReview.repairFindings.filter((finding) => !sourceIds.includes(finding.sourceId)),
3001
+ };
3002
+ const pullRequest = await this.dependencies.pullRequests.findOpen({
3003
+ headBranch: active.record.branchName,
3004
+ baseBranch: config.github.baseBranch,
3005
+ });
3006
+ if (!pullRequest?.url || pullRequest.number !== batch.pullRequest.number
3007
+ || pullRequest.nodeId !== batch.pullRequest.nodeId || pullRequest.headSha !== batch.pullRequest.headSha
3008
+ || pullRequest.headRefName !== batch.pullRequest.headRefName
3009
+ || pullRequest.baseRefName !== batch.pullRequest.baseRefName) {
3010
+ return this.terminal(active, { status: 'blocked', kind: 'safety', resumable: false }, 'issue-feedback-prior-handoff-identity-mismatch', { reviewFeedback: blockReviewFeedback({
3011
+ ...feedback,
3012
+ consumedSourceIds: feedback.consumedSourceIds.filter((id) => !sourceIds.includes(id)),
3013
+ }, 'safety', this.timestamp()) });
3014
+ }
3015
+ const terminalSeed = {
3016
+ status: 'review-ready',
3017
+ pullRequestUrl: pullRequest.url,
3018
+ continuationEpoch: batch.priorPublishedHeadSha,
3019
+ };
3020
+ const terminalOutcome = {
3021
+ ...terminalSeed,
3022
+ evidencePath: this.dependencies.outcomeEvidencePath(active.record.runId, 'review-ready', sha256(canonicalJson(terminalSeed))),
3023
+ };
3024
+ const respondedAt = this.timestamp();
3025
+ const initialReceipt = {
3026
+ kind: 'responded',
3027
+ batchId: batch.batchId,
3028
+ sourceIds,
3029
+ responseKind: report.status,
3030
+ publication: 'failed',
3031
+ diagnostic: 'issue-feedback-response-publication-not-settled',
3032
+ respondedAt,
3033
+ };
3034
+ let comments = active.record.issueSnapshot.comments ?? [];
3035
+ try {
3036
+ comments = (await this.readIssue(active.record.issueNumber))?.comments ?? comments;
3010
3037
  }
3011
- else if (existing.kind !== 'blocked-labels'
3012
- || existing.issueNumber !== active.record.issueNumber
3013
- || existing.blockKind !== outcome.kind
3014
- || existing.resumable !== outcome.resumable
3015
- || existing.evidenceCode !== evidenceCode) {
3016
- return this.invokedFailure(active, 'blocked-labels-pendingEffect-diverged');
3038
+ catch { /* bounded fallback */ }
3039
+ const freshNotifications = terminalNotificationState(active.record, terminalSeed, this.timestamp(), comments);
3040
+ const consumedChanges = {
3041
+ lifecycle: 'review-ready',
3042
+ terminalOutcome,
3043
+ outcomeEvidenceId: `evidence:${active.record.runId}:review-ready`,
3044
+ reviewFeedback: respondReviewFeedback(feedback, initialReceipt),
3045
+ directReview: restoredDirectReview,
3046
+ reworkFindings: [],
3047
+ reportRepairs: 0,
3048
+ transportRetries: 0,
3049
+ terminalNotifications: {
3050
+ ...freshNotifications,
3051
+ comment: { status: 'delivered', attempts: 0 },
3052
+ labels: { status: 'delivered', attempts: 0 },
3053
+ },
3054
+ };
3055
+ active = isAdoptableAttempt(active.record.activeAttempt)
3056
+ ? await this.adoptAttempt(active, sha256(canonicalJson(report)), consumedChanges)
3057
+ : await this.persist(active, consumedChanges);
3058
+ let publication = 'failed';
3059
+ let commentId;
3060
+ let diagnostic;
3061
+ const validation = this.dependencies.reviewFeedback
3062
+ ? await this.dependencies.reviewFeedback.revalidate({
3063
+ batch, issueNumber: active.record.issueNumber, epoch: 'pre-update', expectedHeadSha: batch.priorPublishedHeadSha,
3064
+ })
3065
+ : { status: 'blocked', reason: 'review feedback revalidation unavailable' };
3066
+ if (validation.status !== 'valid') {
3067
+ publication = 'suppressed';
3068
+ diagnostic = validation.status === 'retryable'
3069
+ ? 'issue-feedback-response-revalidation-retryable'
3070
+ : 'issue-feedback-response-revalidation-blocked';
3017
3071
  }
3018
3072
  else {
3019
- expected = existing.expected;
3020
- if (!sameStrings(expected, autoBlocked) && !sameStrings(expected, blockedOnly) && expected.length !== 0) {
3021
- return this.invokedFailure(active, 'blocked-labels-pendingEffect-diverged');
3022
- }
3023
- }
3024
- let issue = await this.readIssue(active.record.issueNumber);
3025
- if (!issue)
3026
- return this.invokedFailure(active, 'blocked-labels-issue-missing');
3027
- let projection = blockedLabelProjection(issue.labels, active.config);
3028
- if (issue.state === 'OPEN' && projection.status === 'transition') {
3029
- if (!this.dependencies.issues.transitionToBlocked) {
3030
- return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
3031
- }
3032
3073
  try {
3033
- await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
3074
+ let issue = await this.readIssue(active.record.issueNumber);
3075
+ let matches = issue ? commentsWithMarker(issue, marker) : [];
3076
+ if (matches.length === 0) {
3077
+ await this.dependencies.issues.postComment(active.record.issueNumber, body);
3078
+ issue = await this.readIssue(active.record.issueNumber);
3079
+ matches = issue ? commentsWithMarker(issue, marker) : [];
3080
+ }
3081
+ const match = matches.length === 1 && sha256(matches[0].body) === sha256(body) ? matches[0] : undefined;
3082
+ if (match) {
3083
+ publication = 'delivered';
3084
+ commentId = match.id ?? `marker:${batch.batchId}`;
3085
+ }
3086
+ else {
3087
+ diagnostic = 'issue-feedback-response-observation-diverged';
3088
+ }
3034
3089
  }
3035
3090
  catch {
3036
- return this.invokedFailure(active, 'blocked-labels-delivery-unknown');
3091
+ diagnostic = 'issue-feedback-response-delivery-failed';
3037
3092
  }
3038
- issue = await this.readIssue(active.record.issueNumber);
3039
- projection = issue ? blockedLabelProjection(issue.labels, active.config) : { status: 'diverged' };
3040
3093
  }
3041
- if (!issue)
3042
- return this.invokedFailure(active, 'blocked-labels-observation-missing');
3043
- if (issue.state === 'OPEN') {
3044
- if (projection.status !== 'settled') {
3045
- return this.invokedFailure(active, 'blocked-labels-observation-diverged');
3046
- }
3047
- if (!sameStrings(expected, projection.expected)) {
3048
- expected = projection.expected;
3049
- const pendingEffect = active.record.pendingEffect;
3050
- if (pendingEffect?.kind !== 'blocked-labels')
3051
- return this.invokedFailure(active, 'blocked-labels-pendingEffect-diverged');
3052
- try {
3053
- const { effectId: _effectId, ...intent } = pendingEffect;
3054
- active = await this.persist(active, { pendingEffect: { ...intent, expected } });
3055
- }
3056
- catch {
3057
- throw new PostEffectStateError(active);
3058
- }
3059
- }
3094
+ try {
3095
+ await this.dependencies.issues.setLabels(active.record.issueNumber, [config.github.labels.review.name]);
3096
+ }
3097
+ catch {
3098
+ diagnostic = diagnostic ?? 'issue-feedback-review-label-reconciliation-failed';
3060
3099
  }
3100
+ const receipt = {
3101
+ kind: 'responded',
3102
+ batchId: batch.batchId,
3103
+ sourceIds,
3104
+ responseKind: report.status,
3105
+ publication,
3106
+ ...(commentId ? { commentId } : {}),
3107
+ ...(diagnostic ? { diagnostic } : {}),
3108
+ respondedAt,
3109
+ };
3061
3110
  try {
3062
- active = await this.confirmEffect(active);
3063
- return await this.persistTerminal(active, outcome, evidenceCode, false);
3111
+ active = await this.persist(active, {
3112
+ reviewFeedback: settleReviewFeedbackResponse(active.record.reviewFeedback, receipt),
3113
+ });
3064
3114
  }
3065
3115
  catch {
3066
- throw new PostEffectStateError(active);
3116
+ return publicOutcome(terminalOutcome);
3117
+ }
3118
+ try {
3119
+ active = await this.clearAttempt(active);
3067
3120
  }
3121
+ catch {
3122
+ return publicOutcome(terminalOutcome);
3123
+ }
3124
+ return publicOutcome(active.record.terminalOutcome);
3068
3125
  }
3069
- async reconcilePersistedBlockedTerminal(active, issue, outcome) {
3070
- if (!this.dependencies.issues.transitionToBlocked) {
3071
- return this.invokedFailure(active, 'blocked-labels-transition-unavailable');
3126
+ async terminal(active, outcome, evidenceCode = outcome.status, additionalChanges = {}) {
3127
+ if (active.record.pendingEffect && active.record.pendingEffect.kind !== 'outcome-evidence') {
3128
+ return this.invokedFailure(active, 'terminal-pending-effect-unsettled', 'Terminal state requires the existing effect postcondition to settle first.');
3072
3129
  }
3130
+ const feedback = active.record.reviewFeedback;
3131
+ const terminalChanges = feedback?.activeBatch && !additionalChanges.reviewFeedback ? {
3132
+ ...additionalChanges,
3133
+ reviewFeedback: blockReviewFeedback(feedback, outcome.status === 'blocked' ? outcome.kind : 'safety', this.timestamp()),
3134
+ } : additionalChanges;
3135
+ return this.persistTerminal(active, outcome, evidenceCode, terminalChanges);
3136
+ }
3137
+ async settleTerminalNotificationsBestEffort(starting) {
3138
+ let active = starting;
3139
+ if (!active.record.terminalOutcome || !active.record.terminalNotifications)
3140
+ return active;
3141
+ if (active.record.pendingEffect && active.record.pendingEffect.kind !== 'terminal-comment'
3142
+ && active.record.pendingEffect.kind !== 'terminal-labels')
3143
+ return active;
3073
3144
  try {
3074
- await this.dependencies.issues.transitionToBlocked(active.record.issueNumber, blockedLabelPolicy(active.config));
3145
+ active = await this.settleTerminalComment(active);
3075
3146
  }
3076
3147
  catch {
3077
- return this.invokedFailure(active, 'blocked-labels-delivery-unknown');
3148
+ return active;
3078
3149
  }
3079
- const observation = await this.readIssue(active.record.issueNumber);
3080
- if (!observation || observation.state !== 'OPEN'
3081
- || blockedLabelProjection(observation.labels, active.config).status !== 'settled') {
3082
- return this.invokedFailure(active, 'blocked-labels-observation-diverged');
3150
+ try {
3151
+ active = await this.settleTerminalLabels(active);
3083
3152
  }
3084
- return publicOutcome(outcome);
3153
+ catch {
3154
+ return active;
3155
+ }
3156
+ return active;
3085
3157
  }
3086
- async persistTerminal(active, outcome, evidenceCode, retainIntent, additionalChanges = {}, replaceConfirmedEffect = false) {
3087
- const evidence = await this.observeOutcomeEvidenceEffect(active, evidenceCode, canonicalJson(outcome), replaceConfirmedEffect);
3158
+ async settleTerminalComment(starting) {
3159
+ let active = starting;
3160
+ const notifications = active.record.terminalNotifications;
3161
+ if (notifications.comment.status !== 'pending' || notifications.comment.attempts >= TERMINAL_NOTIFICATION_ATTEMPTS)
3162
+ return active;
3163
+ const body = terminalComment(active.record.runId, active.record.cycle, notifications.report);
3164
+ const marker = body.split('\n')[0];
3165
+ const attempt = notifications.comment.attempts + 1;
3166
+ if (!active.record.pendingEffect) {
3167
+ active = await this.persist(active, { pendingEffect: {
3168
+ kind: 'terminal-comment', issueNumber: active.record.issueNumber, marker, bodySha256: sha256(body),
3169
+ outcome: notifications.report.outcome, attempt,
3170
+ } });
3171
+ }
3172
+ const effect = active.record.pendingEffect;
3173
+ if (effect?.kind !== 'terminal-comment' || effect.issueNumber !== active.record.issueNumber
3174
+ || effect.marker !== marker || effect.bodySha256 !== sha256(body)
3175
+ || effect.outcome !== notifications.report.outcome || effect.attempt !== attempt)
3176
+ return active;
3177
+ const settlement = await settleCommentEffect(effect, {
3178
+ observe: async () => {
3179
+ const issue = await this.readIssue(effect.issueNumber);
3180
+ if (!issue || issue.state !== 'OPEN')
3181
+ return 'diverged';
3182
+ const matching = commentsWithMarker(issue, marker);
3183
+ if (matching.length === 0)
3184
+ return 'absent';
3185
+ return matching.length === 1 && sha256(matching[0].body) === effect.bodySha256 ? 'confirmed' : 'diverged';
3186
+ },
3187
+ invoke: () => this.dependencies.issues.postComment(effect.issueNumber, body),
3188
+ });
3189
+ const delivered = settlement.status === 'confirmed';
3190
+ const status = delivered ? 'delivered'
3191
+ : attempt >= TERMINAL_NOTIFICATION_ATTEMPTS ? 'exhausted' : 'pending';
3192
+ const diagnostic = delivered ? {} : { diagnostic: terminalNotificationDiagnostic('comment', settlement.status) };
3193
+ return this.persist(active, {
3194
+ terminalNotifications: {
3195
+ ...notifications,
3196
+ comment: { status, attempts: attempt, ...diagnostic },
3197
+ },
3198
+ pendingEffect: undefined,
3199
+ });
3200
+ }
3201
+ async settleTerminalLabels(starting) {
3202
+ let active = starting;
3203
+ let notifications = active.record.terminalNotifications;
3204
+ const policy = terminalLabelChanges(active.config, notifications.report.outcome);
3205
+ if (notifications.labels.status === 'delivered') {
3206
+ const issue = await this.readIssue(active.record.issueNumber);
3207
+ const settled = issue && policy.add.every((label) => issue.labels.includes(label))
3208
+ && policy.remove.every((label) => !issue.labels.includes(label));
3209
+ if (settled)
3210
+ return active;
3211
+ notifications = { ...notifications, labels: { status: 'pending', attempts: 0 } };
3212
+ active = await this.persist(active, { terminalNotifications: notifications });
3213
+ }
3214
+ if (notifications.labels.status !== 'pending' || notifications.labels.attempts >= TERMINAL_NOTIFICATION_ATTEMPTS)
3215
+ return active;
3216
+ const attempt = notifications.labels.attempts + 1;
3217
+ if (!active.record.pendingEffect) {
3218
+ active = await this.persist(active, { pendingEffect: {
3219
+ kind: 'terminal-labels', issueNumber: active.record.issueNumber, ...policy,
3220
+ outcome: notifications.report.outcome, attempt,
3221
+ } });
3222
+ }
3223
+ const effect = active.record.pendingEffect;
3224
+ if (effect?.kind !== 'terminal-labels' || effect.issueNumber !== active.record.issueNumber
3225
+ || effect.outcome !== notifications.report.outcome || effect.attempt !== attempt
3226
+ || !sameStrings(effect.add, policy.add) || !sameStrings(effect.remove, policy.remove))
3227
+ return active;
3228
+ const settlement = await settleLabelsEffect(effect, {
3229
+ observe: async () => {
3230
+ const issue = await this.readIssue(effect.issueNumber);
3231
+ if (!issue)
3232
+ return 'diverged';
3233
+ return effect.add.every((label) => issue.labels.includes(label))
3234
+ && effect.remove.every((label) => !issue.labels.includes(label)) ? 'confirmed' : 'absent';
3235
+ },
3236
+ invoke: async () => {
3237
+ if (!this.dependencies.issues.reconcileTerminalLabels)
3238
+ throw new Error('terminal label reconciliation unavailable');
3239
+ await this.dependencies.issues.reconcileTerminalLabels(effect.issueNumber, {
3240
+ outcome: effect.outcome, add: effect.add, remove: effect.remove,
3241
+ });
3242
+ },
3243
+ });
3244
+ const delivered = settlement.status === 'confirmed';
3245
+ const status = delivered ? 'delivered'
3246
+ : attempt >= TERMINAL_NOTIFICATION_ATTEMPTS ? 'exhausted' : 'pending';
3247
+ const diagnostic = delivered ? {} : { diagnostic: terminalNotificationDiagnostic('labels', settlement.status) };
3248
+ return this.persist(active, {
3249
+ terminalNotifications: {
3250
+ ...notifications,
3251
+ labels: { status, attempts: attempt, ...diagnostic },
3252
+ },
3253
+ pendingEffect: undefined,
3254
+ });
3255
+ }
3256
+ async persistTerminal(active, outcome, evidenceCode, additionalChanges = {}) {
3257
+ const evidence = await this.observeOutcomeEvidenceEffect(active, evidenceCode, canonicalJson(outcome));
3088
3258
  active = evidence.active;
3089
3259
  const terminalOutcome = { ...outcome, evidencePath: evidence.path };
3260
+ let terminalComments = active.record.issueSnapshot.comments ?? [];
3261
+ if (outcome.status !== 'transport-failed') {
3262
+ try {
3263
+ terminalComments = (await this.readIssue(active.record.issueNumber))?.comments ?? terminalComments;
3264
+ }
3265
+ catch {
3266
+ // Terminal notification observation is best-effort and cannot change the Run outcome.
3267
+ }
3268
+ }
3269
+ const notifications = terminalNotificationState(active.record, outcome, this.timestamp(), terminalComments);
3090
3270
  const changes = {
3091
3271
  ...additionalChanges,
3092
3272
  lifecycle: outcome.status,
3093
3273
  terminalOutcome,
3094
3274
  outcomeEvidenceId: evidence.id,
3275
+ ...(notifications ? { terminalNotifications: notifications } : {}),
3095
3276
  };
3096
3277
  if (outcome.status !== 'review-ready' && active.record.directReview && active.record.directReview.status !== 'terminal') {
3097
3278
  changes.directReview = projectTerminalDirectReview(active.record.directReview, outcome.status === 'blocked'
@@ -3099,8 +3280,13 @@ export class RunIssue {
3099
3280
  : { status: outcome.status }, outcome.status === 'internal-error' ? outcome.code : undefined);
3100
3281
  }
3101
3282
  active = await this.persist(active, changes);
3102
- if (!retainIntent)
3283
+ try {
3103
3284
  active = await this.confirmEffect(active);
3285
+ }
3286
+ catch {
3287
+ return publicOutcome(terminalOutcome);
3288
+ }
3289
+ active = await this.settleTerminalNotificationsBestEffort(active);
3104
3290
  return publicOutcome(terminalOutcome);
3105
3291
  }
3106
3292
  async preClaimInternal(code, issueNumber) {
@@ -3138,6 +3324,142 @@ class PostEffectStateError extends Error {
3138
3324
  this.active = active;
3139
3325
  }
3140
3326
  }
3327
+ const TERMINAL_NOTIFICATION_ATTEMPTS = 3;
3328
+ function terminalNotificationState(record, outcome, observedAt, comments) {
3329
+ if (outcome.status === 'transport-failed')
3330
+ return undefined;
3331
+ const commentId = issueCommentHighWaterMark(comments);
3332
+ return {
3333
+ version: 1,
3334
+ commentCutoff: { commentId, observedAt },
3335
+ report: terminalReportSnapshot(record, outcome),
3336
+ comment: { status: 'pending', attempts: 0 },
3337
+ labels: { status: 'pending', attempts: 0 },
3338
+ };
3339
+ }
3340
+ function terminalReportSnapshot(record, outcome) {
3341
+ const summary = outcome.status === 'blocked'
3342
+ ? outcome.blocker?.summary ?? `The run stopped at ${outcome.kind}.`
3343
+ : outcome.status === 'internal-error'
3344
+ ? internalErrorSummary(outcome.code)
3345
+ : outcome.status === 'cancelled'
3346
+ ? 'The run was cancelled before completion.'
3347
+ : record.implementationResult?.summary ?? record.proofReceipt?.summary ?? 'The authorized implementation is ready for review.';
3348
+ const nextAction = outcome.status === 'review-ready'
3349
+ ? 'Review the draft PR. To request an in-scope follow-up, comment on this issue.'
3350
+ : outcome.status === 'blocked'
3351
+ ? outcome.resumable
3352
+ ? 'Resolve the stated blocker, then rerun the same issue.'
3353
+ : 'Provide the missing decision or authority in this issue before continuing.'
3354
+ : outcome.status === 'internal-error'
3355
+ ? 'Inspect the package-owned evidence for the code above, correct the runner condition, and retry the same issue.'
3356
+ : 'Re-add the authorization label and rerun the same issue if the work should continue.';
3357
+ return {
3358
+ version: 1,
3359
+ outcome: outcome.status,
3360
+ summary: publicTerminalText(summary, 'Terminal summary is unavailable.'),
3361
+ ...(outcome.status === 'review-ready' ? { pullRequestUrl: outcome.pullRequestUrl } : {}),
3362
+ passedChecks: record.checks.filter((check) => check.status === 'passed').slice(0, 16)
3363
+ .map((check) => publicTerminalText(check.id, 'Unnamed check.', 200)),
3364
+ publishableProof: (record.proofReceipt?.publishableEvidence ?? []).slice(0, 4).map((proof) => publicTerminalText(`${proof.ref}: ${proof.description}`, 'Publishable proof detail is unavailable.', 500)),
3365
+ unverified: record.proofReceipt ? [] : ['No publishable Acceptance Proof receipt was retained.'],
3366
+ risks: (record.implementationResult?.residualRisks ?? []).slice(0, 4)
3367
+ .map((risk) => publicTerminalText(risk, 'Risk detail is unavailable.', 500)),
3368
+ reviewFocus: (record.directReview?.review.coverage ?? []).slice(0, 8)
3369
+ .map((focus) => publicTerminalText(focus, 'Review focus is unavailable.', 250)),
3370
+ nextAction,
3371
+ ...(outcome.status === 'blocked' ? { blocker: {
3372
+ kind: outcome.kind,
3373
+ resumable: outcome.resumable,
3374
+ attempted: (outcome.blocker?.attempted ?? []).slice(0, 8)
3375
+ .map((attempt) => publicTerminalText(attempt, 'Attempt detail is unavailable.', 500)),
3376
+ } } : {}),
3377
+ };
3378
+ }
3379
+ function internalErrorSummary(code) {
3380
+ return code === 'implementation-change-set-invalid'
3381
+ ? 'The implementation report declared changed files, but the runner found no matching worktree changes.'
3382
+ : `The runner encountered an internal failure (${code}) and could not complete this run.`;
3383
+ }
3384
+ function terminalComment(runId, cycle, report) {
3385
+ const markerKind = report.outcome === 'review-ready' ? 'handoff' : report.outcome;
3386
+ const marker = `<!-- codex-orchestrator:run:${runId}:cycle:${cycle}:${markerKind} -->`;
3387
+ if (report.outcome === 'review-ready') {
3388
+ return [
3389
+ marker,
3390
+ 'codex-orchestrator completed this run.',
3391
+ '',
3392
+ '## Summary', report.summary,
3393
+ '',
3394
+ '## Pull request', report.pullRequestUrl ?? 'Draft PR URL is unavailable.',
3395
+ '',
3396
+ '## Passed checks', ...terminalList(report.passedChecks),
3397
+ '',
3398
+ '## Publishable proof', ...terminalList(report.publishableProof),
3399
+ '',
3400
+ '## Not verified', ...terminalList(report.unverified),
3401
+ '',
3402
+ '## Known risks', ...terminalList(report.risks),
3403
+ '',
3404
+ '## Review focus', ...terminalList(report.reviewFocus),
3405
+ '',
3406
+ '## Follow-up', report.nextAction,
3407
+ ].join('\n');
3408
+ }
3409
+ const title = report.outcome === 'blocked' ? 'blocked' : report.outcome === 'internal-error' ? 'stopped after an internal error' : 'was cancelled';
3410
+ return [
3411
+ marker,
3412
+ `codex-orchestrator ${title} this run.`,
3413
+ '',
3414
+ ...(report.blocker ? [`Kind: ${report.blocker.kind}`, `Resumable: ${report.blocker.resumable ? 'yes' : 'no'}`, ''] : []),
3415
+ '## Reason', `Reason: ${report.summary}`,
3416
+ ...(report.blocker ? ['', 'Attempted:', ...terminalList(report.blocker.attempted)] : []),
3417
+ '',
3418
+ '## Retained work and state',
3419
+ ...terminalList([
3420
+ ...report.passedChecks.map((check) => `Passed check: ${check}`),
3421
+ ...report.publishableProof.map((proof) => `Proof: ${proof}`),
3422
+ ]),
3423
+ '',
3424
+ '## Next action', report.nextAction,
3425
+ ].join('\n');
3426
+ }
3427
+ function terminalList(values) {
3428
+ return values.length > 0 ? values.map((value) => `- ${value}`) : ['- None recorded.'];
3429
+ }
3430
+ function terminalNotificationDiagnostic(kind, status) {
3431
+ return status === 'unknown'
3432
+ ? `terminal-${kind}-delivery-unknown`
3433
+ : `terminal-${kind}-observation-${status}`;
3434
+ }
3435
+ function terminalLabelChanges(config, outcome) {
3436
+ const labels = config.github.labels;
3437
+ if (outcome === 'review-ready')
3438
+ return {
3439
+ add: [labels.review.name], remove: [labels.auto.name, labels.running.name, labels.blocked.name].sort(),
3440
+ };
3441
+ if (outcome === 'blocked')
3442
+ return { add: [labels.blocked.name], remove: [labels.running.name, labels.review.name].sort() };
3443
+ return {
3444
+ add: [],
3445
+ remove: [labels.running.name, labels.blocked.name, labels.review.name, ...(outcome === 'cancelled' ? [labels.auto.name] : [])].sort(),
3446
+ };
3447
+ }
3448
+ function claimLabelProjectionSettled(labels, config) {
3449
+ const policy = config.github.labels;
3450
+ return labels.includes(policy.auto.name) && labels.includes(policy.running.name)
3451
+ && !labels.includes(policy.blocked.name) && !labels.includes(policy.review.name);
3452
+ }
3453
+ function issueCommentHighWaterMark(comments) {
3454
+ const ids = comments.map((comment) => comment.id).filter((id) => Boolean(id));
3455
+ const decimal = ids.filter((id) => /^\d+$/u.test(id));
3456
+ if (decimal.length > 0)
3457
+ return decimal.reduce((highest, id) => BigInt(id) > BigInt(highest) ? id : highest);
3458
+ return ids.at(-1) ?? null;
3459
+ }
3460
+ function publicTerminalText(value, fallback, maxLength = 2_000) {
3461
+ return publicBlockedText(value, fallback, maxLength);
3462
+ }
3141
3463
  function lifecycleForAttempt(operationId) {
3142
3464
  if (operationId === 'configured-check')
3143
3465
  return 'checking';
@@ -3277,6 +3599,27 @@ function pendingCandidateBoundary(record) {
3277
3599
  function commentsWithMarker(issue, marker) {
3278
3600
  return issue.comments.filter((comment) => comment.body.split('\n')[0] === marker);
3279
3601
  }
3602
+ function managedLabelProjection(labels, config) {
3603
+ const policy = config.github.labels;
3604
+ const managed = new Set([policy.auto.name, policy.running.name, policy.blocked.name, policy.review.name]);
3605
+ return labels.filter((label) => managed.has(label)).sort();
3606
+ }
3607
+ function publicBlockedText(value, fallback, maxLength) {
3608
+ if (containsCredentialEvidence(value) || containsHostIdentityEvidence(value)
3609
+ || /["']?token["']?\s*[:=]\s*["']?[^\s"']{8,}/iu.test(value))
3610
+ return fallback;
3611
+ const normalized = value.replace(/[\r\n]+/gu, ' ').trim();
3612
+ if (normalized.length === 0)
3613
+ return fallback;
3614
+ return normalized.length <= maxLength
3615
+ ? normalized
3616
+ : `${normalized.slice(0, Math.max(0, maxLength - 12))} [truncated]`;
3617
+ }
3618
+ function publicIssueFeedbackText(value, fallback) {
3619
+ if (/\b(?:chain[- ]of[- ]thought|reasoning artifact|local-only log|private reasoning|debug log)\b/iu.test(value))
3620
+ return fallback;
3621
+ return publicBlockedText(value, fallback, 4_000);
3622
+ }
3280
3623
  function sortedUnique(values) {
3281
3624
  return [...new Set(values)].sort();
3282
3625
  }