@generacy-ai/orchestrator 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/dist/config/loader.d.ts.map +1 -1
  2. package/dist/config/loader.js +32 -1
  3. package/dist/config/loader.js.map +1 -1
  4. package/dist/config/schema.d.ts +436 -7
  5. package/dist/config/schema.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/launcher/agent-launcher.d.ts +15 -16
  11. package/dist/launcher/agent-launcher.d.ts.map +1 -1
  12. package/dist/launcher/agent-launcher.js +54 -23
  13. package/dist/launcher/agent-launcher.js.map +1 -1
  14. package/dist/launcher/constants.d.ts +5 -0
  15. package/dist/launcher/constants.d.ts.map +1 -0
  16. package/dist/launcher/constants.js +5 -0
  17. package/dist/launcher/constants.js.map +1 -0
  18. package/dist/launcher/errors.d.ts +15 -0
  19. package/dist/launcher/errors.d.ts.map +1 -0
  20. package/dist/launcher/errors.js +25 -0
  21. package/dist/launcher/errors.js.map +1 -0
  22. package/dist/launcher/generic-subprocess-plugin.d.ts +1 -0
  23. package/dist/launcher/generic-subprocess-plugin.d.ts.map +1 -1
  24. package/dist/launcher/generic-subprocess-plugin.js +2 -0
  25. package/dist/launcher/generic-subprocess-plugin.js.map +1 -1
  26. package/dist/launcher/index.d.ts +2 -1
  27. package/dist/launcher/index.d.ts.map +1 -1
  28. package/dist/launcher/index.js +1 -0
  29. package/dist/launcher/index.js.map +1 -1
  30. package/dist/launcher/types.d.ts +81 -4
  31. package/dist/launcher/types.d.ts.map +1 -1
  32. package/dist/routes/health.d.ts.map +1 -1
  33. package/dist/routes/health.js +5 -0
  34. package/dist/routes/health.js.map +1 -1
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +130 -26
  37. package/dist/server.js.map +1 -1
  38. package/dist/services/base-advance-monitor-service.d.ts +78 -0
  39. package/dist/services/base-advance-monitor-service.d.ts.map +1 -0
  40. package/dist/services/base-advance-monitor-service.js +233 -0
  41. package/dist/services/base-advance-monitor-service.js.map +1 -0
  42. package/dist/services/boot-resume-service.d.ts +19 -0
  43. package/dist/services/boot-resume-service.d.ts.map +1 -0
  44. package/dist/services/boot-resume-service.js +87 -0
  45. package/dist/services/boot-resume-service.js.map +1 -0
  46. package/dist/services/epic-completion-monitor-service.d.ts.map +1 -1
  47. package/dist/services/epic-completion-monitor-service.js +2 -0
  48. package/dist/services/epic-completion-monitor-service.js.map +1 -1
  49. package/dist/services/in-memory-queue-adapter.d.ts +4 -0
  50. package/dist/services/in-memory-queue-adapter.d.ts.map +1 -1
  51. package/dist/services/in-memory-queue-adapter.js +30 -3
  52. package/dist/services/in-memory-queue-adapter.js.map +1 -1
  53. package/dist/services/label-monitor-service.d.ts +3 -3
  54. package/dist/services/label-monitor-service.d.ts.map +1 -1
  55. package/dist/services/label-monitor-service.js +58 -41
  56. package/dist/services/label-monitor-service.js.map +1 -1
  57. package/dist/services/label-sync-service.d.ts.map +1 -1
  58. package/dist/services/label-sync-service.js +45 -10
  59. package/dist/services/label-sync-service.js.map +1 -1
  60. package/dist/services/merge-conflict-monitor-service.d.ts +73 -0
  61. package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -0
  62. package/dist/services/merge-conflict-monitor-service.js +295 -0
  63. package/dist/services/merge-conflict-monitor-service.js.map +1 -0
  64. package/dist/services/orchestrator-version.d.ts +2 -0
  65. package/dist/services/orchestrator-version.d.ts.map +1 -0
  66. package/dist/services/orchestrator-version.js +23 -0
  67. package/dist/services/orchestrator-version.js.map +1 -0
  68. package/dist/services/phase-tracker-service.d.ts +10 -0
  69. package/dist/services/phase-tracker-service.d.ts.map +1 -1
  70. package/dist/services/phase-tracker-service.js +38 -0
  71. package/dist/services/phase-tracker-service.js.map +1 -1
  72. package/dist/services/post-activation-dispatch.d.ts +18 -0
  73. package/dist/services/post-activation-dispatch.d.ts.map +1 -0
  74. package/dist/services/post-activation-dispatch.js +30 -0
  75. package/dist/services/post-activation-dispatch.js.map +1 -0
  76. package/dist/services/post-activation-retry.d.ts +3 -0
  77. package/dist/services/post-activation-retry.d.ts.map +1 -1
  78. package/dist/services/post-activation-retry.js +34 -2
  79. package/dist/services/post-activation-retry.js.map +1 -1
  80. package/dist/services/pr-feedback-monitor-service.d.ts +13 -4
  81. package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
  82. package/dist/services/pr-feedback-monitor-service.js +212 -26
  83. package/dist/services/pr-feedback-monitor-service.js.map +1 -1
  84. package/dist/services/redis-queue-adapter.d.ts +4 -0
  85. package/dist/services/redis-queue-adapter.d.ts.map +1 -1
  86. package/dist/services/redis-queue-adapter.js +90 -10
  87. package/dist/services/redis-queue-adapter.js.map +1 -1
  88. package/dist/services/worker-dispatcher.d.ts +14 -2
  89. package/dist/services/worker-dispatcher.d.ts.map +1 -1
  90. package/dist/services/worker-dispatcher.js +55 -2
  91. package/dist/services/worker-dispatcher.js.map +1 -1
  92. package/dist/types/api.d.ts +3 -0
  93. package/dist/types/api.d.ts.map +1 -1
  94. package/dist/types/api.js +1 -0
  95. package/dist/types/api.js.map +1 -1
  96. package/dist/types/index.d.ts +1 -1
  97. package/dist/types/index.d.ts.map +1 -1
  98. package/dist/types/index.js.map +1 -1
  99. package/dist/types/monitor.d.ts +72 -4
  100. package/dist/types/monitor.d.ts.map +1 -1
  101. package/dist/worker/base-merge.d.ts +54 -0
  102. package/dist/worker/base-merge.d.ts.map +1 -0
  103. package/dist/worker/base-merge.js +99 -0
  104. package/dist/worker/base-merge.js.map +1 -0
  105. package/dist/worker/clarification-markers.d.ts +29 -0
  106. package/dist/worker/clarification-markers.d.ts.map +1 -0
  107. package/dist/worker/clarification-markers.js +44 -0
  108. package/dist/worker/clarification-markers.js.map +1 -0
  109. package/dist/worker/clarification-poster.d.ts.map +1 -1
  110. package/dist/worker/clarification-poster.js +266 -25
  111. package/dist/worker/clarification-poster.js.map +1 -1
  112. package/dist/worker/claude-cli-worker.d.ts +21 -2
  113. package/dist/worker/claude-cli-worker.d.ts.map +1 -1
  114. package/dist/worker/claude-cli-worker.js +150 -4
  115. package/dist/worker/claude-cli-worker.js.map +1 -1
  116. package/dist/worker/cli-spawner.d.ts +4 -0
  117. package/dist/worker/cli-spawner.d.ts.map +1 -1
  118. package/dist/worker/cli-spawner.js +53 -7
  119. package/dist/worker/cli-spawner.js.map +1 -1
  120. package/dist/worker/config.d.ts +420 -8
  121. package/dist/worker/config.d.ts.map +1 -1
  122. package/dist/worker/config.js +179 -2
  123. package/dist/worker/config.js.map +1 -1
  124. package/dist/worker/evidence-hash.d.ts +32 -0
  125. package/dist/worker/evidence-hash.d.ts.map +1 -0
  126. package/dist/worker/evidence-hash.js +139 -0
  127. package/dist/worker/evidence-hash.js.map +1 -0
  128. package/dist/worker/handler-outcome-assertion.d.ts +38 -0
  129. package/dist/worker/handler-outcome-assertion.d.ts.map +1 -0
  130. package/dist/worker/handler-outcome-assertion.js +58 -0
  131. package/dist/worker/handler-outcome-assertion.js.map +1 -0
  132. package/dist/worker/handler-outcome.d.ts +50 -0
  133. package/dist/worker/handler-outcome.d.ts.map +1 -0
  134. package/dist/worker/handler-outcome.js +2 -0
  135. package/dist/worker/handler-outcome.js.map +1 -0
  136. package/dist/worker/label-manager.d.ts +58 -2
  137. package/dist/worker/label-manager.d.ts.map +1 -1
  138. package/dist/worker/label-manager.js +225 -27
  139. package/dist/worker/label-manager.js.map +1 -1
  140. package/dist/worker/merge-conflict-handler.d.ts +141 -0
  141. package/dist/worker/merge-conflict-handler.d.ts.map +1 -0
  142. package/dist/worker/merge-conflict-handler.js +642 -0
  143. package/dist/worker/merge-conflict-handler.js.map +1 -0
  144. package/dist/worker/merge-conflict-prompt.d.ts +35 -0
  145. package/dist/worker/merge-conflict-prompt.d.ts.map +1 -0
  146. package/dist/worker/merge-conflict-prompt.js +71 -0
  147. package/dist/worker/merge-conflict-prompt.js.map +1 -0
  148. package/dist/worker/merge-conflict-remedy.d.ts +42 -0
  149. package/dist/worker/merge-conflict-remedy.d.ts.map +1 -0
  150. package/dist/worker/merge-conflict-remedy.js +28 -0
  151. package/dist/worker/merge-conflict-remedy.js.map +1 -0
  152. package/dist/worker/output-tail-synthesis.d.ts +15 -0
  153. package/dist/worker/output-tail-synthesis.d.ts.map +1 -0
  154. package/dist/worker/output-tail-synthesis.js +25 -0
  155. package/dist/worker/output-tail-synthesis.js.map +1 -0
  156. package/dist/worker/output-tail.d.ts +13 -0
  157. package/dist/worker/output-tail.d.ts.map +1 -0
  158. package/dist/worker/output-tail.js +31 -0
  159. package/dist/worker/output-tail.js.map +1 -0
  160. package/dist/worker/pause-context.d.ts +30 -0
  161. package/dist/worker/pause-context.d.ts.map +1 -0
  162. package/dist/worker/pause-context.js +98 -0
  163. package/dist/worker/pause-context.js.map +1 -0
  164. package/dist/worker/phase-loop.d.ts +66 -0
  165. package/dist/worker/phase-loop.d.ts.map +1 -1
  166. package/dist/worker/phase-loop.js +329 -30
  167. package/dist/worker/phase-loop.js.map +1 -1
  168. package/dist/worker/pr-feedback-handler.d.ts +46 -46
  169. package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
  170. package/dist/worker/pr-feedback-handler.js +286 -108
  171. package/dist/worker/pr-feedback-handler.js.map +1 -1
  172. package/dist/worker/pr-manager.d.ts +5 -0
  173. package/dist/worker/pr-manager.d.ts.map +1 -1
  174. package/dist/worker/pr-manager.js +7 -0
  175. package/dist/worker/pr-manager.js.map +1 -1
  176. package/dist/worker/product-diff.d.ts +39 -0
  177. package/dist/worker/product-diff.d.ts.map +1 -0
  178. package/dist/worker/product-diff.js +43 -0
  179. package/dist/worker/product-diff.js.map +1 -0
  180. package/dist/worker/provisioning-failure.d.ts +14 -0
  181. package/dist/worker/provisioning-failure.d.ts.map +1 -0
  182. package/dist/worker/provisioning-failure.js +2 -0
  183. package/dist/worker/provisioning-failure.js.map +1 -0
  184. package/dist/worker/repo-checkout.d.ts +14 -0
  185. package/dist/worker/repo-checkout.d.ts.map +1 -1
  186. package/dist/worker/repo-checkout.js +19 -1
  187. package/dist/worker/repo-checkout.js.map +1 -1
  188. package/dist/worker/stage-comment-manager.d.ts +42 -1
  189. package/dist/worker/stage-comment-manager.d.ts.map +1 -1
  190. package/dist/worker/stage-comment-manager.js +205 -1
  191. package/dist/worker/stage-comment-manager.js.map +1 -1
  192. package/dist/worker/terminal-label-op-error.d.ts +23 -0
  193. package/dist/worker/terminal-label-op-error.d.ts.map +1 -0
  194. package/dist/worker/terminal-label-op-error.js +27 -0
  195. package/dist/worker/terminal-label-op-error.js.map +1 -0
  196. package/dist/worker/types.d.ts +181 -2
  197. package/dist/worker/types.d.ts.map +1 -1
  198. package/dist/worker/types.js +11 -0
  199. package/dist/worker/types.js.map +1 -1
  200. package/dist/worker/validate-fix-handler.d.ts +79 -0
  201. package/dist/worker/validate-fix-handler.d.ts.map +1 -0
  202. package/dist/worker/validate-fix-handler.js +320 -0
  203. package/dist/worker/validate-fix-handler.js.map +1 -0
  204. package/dist/worker/worker-result.d.ts +39 -0
  205. package/dist/worker/worker-result.d.ts.map +1 -0
  206. package/dist/worker/worker-result.js +10 -0
  207. package/dist/worker/worker-result.js.map +1 -0
  208. package/package.json +12 -7
@@ -10,48 +10,22 @@ import type { AgentLauncher } from '../launcher/agent-launcher.js';
10
10
  * 1. Extract PR number from queue item metadata
11
11
  * 2. Fetch the PR to get the branch name
12
12
  * 3. Switch to the PR branch (not default branch)
13
- * 4. Fetch fresh unresolved review threads
14
- * 5. Build a structured prompt with all unresolved comments
13
+ * 4. Fetch fresh unresolved review threads (trust-filtered per-thread)
14
+ * 5. Build a structured prompt with all trusted unresolved comments
15
15
  * 6. Spawn Claude CLI to address the feedback
16
- * 7. Stage, commit, and push changes to the PR branch
17
- * 8. Reply to each review thread (never resolve)
18
- * 9. Remove the `waiting-for:address-pr-feedback` label
16
+ * 7. Commit + push. If CLI failed OR no diff → Disposition B (blocked)
17
+ * 8. Otherwise: for each trusted unresolved thread post one reply targeting
18
+ * the root comment, then call `resolveReviewThread(thread.id)`
19
+ * 9. Strict-decrease success test — R = count of resolves that succeeded.
20
+ * R === 0 → Disposition B (blocked); R ≥ 1 → Disposition A (success):
21
+ * warn once per persistently-failed thread, then remove the
22
+ * `waiting-for:address-pr-feedback` label
19
23
  *
20
- * Error handling strategy (T019):
21
- *
22
- * **FR-013: Timeout handling**
23
- * - If CLI times out: push any partial changes that were made
24
- * - Keep `waiting-for:address-pr-feedback` label (don't remove)
25
- * - Label retention ensures the task will be retried on next detection cycle
26
- * - Log timeout warning with structured context
27
- *
28
- * **FR-007: Reply failure handling**
29
- * - If posting replies fails for some threads: still remove label
30
- * - Log warnings for each failed reply
31
- * - Partial reply success is acceptable (code changes were pushed successfully)
32
- * - Handler does not throw on reply failures
33
- *
34
- * **SC-006: Thread resolution prevention**
35
- * - Only use `replyToPRComment()` API — never call any thread-resolve API
36
- * - Human reviewer will resolve threads after verifying changes
37
- * - Comments in code explicitly reference SC-006 for clarity
38
- *
39
- * **Structured logging**
40
- * - All operations logged with structured context (prNumber, issueNumber, owner, repo)
41
- * - Error logs include full error messages and relevant context
42
- * - Success/failure states clearly logged for observability
43
- * - All FR/SC references included in log messages for traceability
44
- *
45
- * **Critical operation failures**
46
- * - PR fetch failure: throw (cannot proceed without PR details)
47
- * - Branch switch failure: throw (wrong branch would cause issues)
48
- * - Comment fetch failure: throw (cannot address unknown feedback)
49
- * - Push failure: log error but don't throw (allows retry via label)
50
- *
51
- * **Non-critical operation failures**
52
- * - No unresolved threads: remove label, early return (success case)
53
- * - Reply posting: log warning, continue (FR-007)
54
- * - Label removal: log warning, continue (non-fatal)
24
+ * Disposition B (blocked, #883): add `blocked:stuck-feedback-loop` and leave
25
+ * `waiting-for:address-pr-feedback` in place. The monitor's pre-enqueue
26
+ * `blocked:*` check keeps the loop paused until an operator removes the
27
+ * label. This ends the runaway "reply-only" cycle (5→10→20→…) observed on
28
+ * christrudelpw/sniplink#4.
55
29
  */
56
30
  export declare class PrFeedbackHandler {
57
31
  private readonly config;
@@ -91,16 +65,42 @@ export declare class PrFeedbackHandler {
91
65
  */
92
66
  private commitAndPushChanges;
93
67
  /**
94
- * Post a reply to each unresolved review thread.
95
- *
96
- * SC-006: Never resolves threads that's left to the human reviewer.
97
- * FR-007: Partial failure is acceptable: if some replies fail, log warnings but continue.
98
- * The handler will still remove the label even if some replies fail.
68
+ * Post a single reply targeting the root comment of a review thread.
69
+ * Returns a discriminated result — failures do not throw so the caller
70
+ * (#883 per-thread outcome loop) can aggregate results.
99
71
  */
100
- private replyToThreads;
72
+ private tryPostReply;
73
+ /**
74
+ * Delegate to `github.resolveReviewThread` (which owns the 3× retry). Wraps
75
+ * the result in a discriminated union so the caller can aggregate outcomes
76
+ * without a try/catch (#883).
77
+ */
78
+ private tryResolveReviewThread;
79
+ /**
80
+ * Read the short SHA of the just-pushed HEAD commit. Returns null when the
81
+ * git command fails; caller falls back to `<unknown>` in the reply body —
82
+ * the SHA is decoration, not termination logic (#883).
83
+ */
84
+ private getHeadShortSha;
85
+ /**
86
+ * Add the `blocked:stuck-feedback-loop` label to signal that the fix cycle
87
+ * cannot advance and must not be re-enqueued until the operator removes the
88
+ * label. Non-fatal on failure — leaving `waiting-for:*` in place is the
89
+ * fallback safety net (#883).
90
+ */
91
+ private addBlockedStuckFeedbackLoopLabel;
101
92
  /**
102
93
  * Remove the `waiting-for:address-pr-feedback` label from the linked issue.
103
94
  */
104
95
  private removeFeedbackLabel;
96
+ /**
97
+ * Structural clear of `agent:in-progress` on the linked issue (#926 SC-004).
98
+ * Called from the shared `finally` block in `handle()` — runs on every
99
+ * terminal exit path (Cases A/B, both blocked-stuck dispositions, happy
100
+ * path, and thrown errors). Idempotent: GitHub's `removeLabels` is a no-op
101
+ * when the label is already absent, so the happy-path coalesced removal +
102
+ * this `finally` clear together produce at most one truthful post-state.
103
+ */
104
+ private clearInProgressLabel;
105
105
  }
106
106
  //# sourceMappingURL=pr-feedback-handler.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pr-feedback-handler.d.ts","sourceRoot":"","sources":["../../src/worker/pr-feedback-handler.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AACH,qBAAa,iBAAiB;IAI1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAN9B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;gBAGzB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,aAAa,EAC5B,UAAU,CAAC,EAAE,eAAe,YAAA;IAK/C;;;;;OAKG;IACG,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoKlE;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IA6B3B;;;;;OAKG;YACW,sBAAsB;IA0HpC;;;;OAIG;YACW,oBAAoB;IA2ElC;;;;;;OAMG;YACW,cAAc;IAsD5B;;OAEG;YACW,mBAAmB;CAgBlC"}
1
+ {"version":3,"file":"pr-feedback-handler.d.ts","sourceRoot":"","sources":["../../src/worker/pr-feedback-handler.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,mBAAmB,CAAC;AACvE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,+BAA+B,CAAC;AA6BnE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,iBAAiB;IAI1B,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAN9B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAe;gBAGzB,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,aAAa,EAC5B,UAAU,CAAC,EAAE,eAAe,YAAA;IAK/C;;;;;OAKG;IACG,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA8UlE;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IAgC3B;;;;;OAKG;YACW,sBAAsB;IAkIpC;;;;OAIG;YACW,oBAAoB;IA2ElC;;;;OAIG;YACW,YAAY;IAgB1B;;;;OAIG;YACW,sBAAsB;IAYpC;;;;OAIG;YACW,eAAe;IAe7B;;;;;OAKG;YACW,gCAAgC;IAoB9C;;OAEG;YACW,mBAAmB;IAiBjC;;;;;;;OAOG;YACW,oBAAoB;CAenC"}
@@ -1,7 +1,19 @@
1
- import { createGitHubClient } from '@generacy-ai/workflow-engine';
1
+ import { createGitHubClient, executeCommand, isTrustedCommentAuthor, tryLoadCommentTrustConfig, wrapUntrustedData, } from '@generacy-ai/workflow-engine';
2
+ import { resolveAgentForPhase } from './config.js';
2
3
  import { OutputCapture } from './output-capture.js';
3
4
  import { RepoCheckout } from './repo-checkout.js';
4
5
  import { buildLaunchCredentials } from './credentials-helper.js';
6
+ /** Label added by the handler when the fix cycle cannot advance (#883). */
7
+ const BLOCKED_STUCK_FEEDBACK_LOOP_LABEL = 'blocked:stuck-feedback-loop';
8
+ /**
9
+ * `agent:in-progress` label — cleared structurally at the single shared exit
10
+ * path (#926 SC-004/SC-005). Extracted to a module constant so the literal
11
+ * appears at exactly one code site: the coalesced happy-path removal and the
12
+ * `clearInProgressLabel` fallback both reference this constant.
13
+ */
14
+ const AGENT_IN_PROGRESS_LABEL = 'agent:in-progress';
15
+ /** Waiting gate cleared alongside `agent:in-progress` on the happy path. */
16
+ const WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL = 'waiting-for:address-pr-feedback';
5
17
  /**
6
18
  * Handles the `address-pr-feedback` command.
7
19
  *
@@ -9,48 +21,22 @@ import { buildLaunchCredentials } from './credentials-helper.js';
9
21
  * 1. Extract PR number from queue item metadata
10
22
  * 2. Fetch the PR to get the branch name
11
23
  * 3. Switch to the PR branch (not default branch)
12
- * 4. Fetch fresh unresolved review threads
13
- * 5. Build a structured prompt with all unresolved comments
24
+ * 4. Fetch fresh unresolved review threads (trust-filtered per-thread)
25
+ * 5. Build a structured prompt with all trusted unresolved comments
14
26
  * 6. Spawn Claude CLI to address the feedback
15
- * 7. Stage, commit, and push changes to the PR branch
16
- * 8. Reply to each review thread (never resolve)
17
- * 9. Remove the `waiting-for:address-pr-feedback` label
18
- *
19
- * Error handling strategy (T019):
20
- *
21
- * **FR-013: Timeout handling**
22
- * - If CLI times out: push any partial changes that were made
23
- * - Keep `waiting-for:address-pr-feedback` label (don't remove)
24
- * - Label retention ensures the task will be retried on next detection cycle
25
- * - Log timeout warning with structured context
26
- *
27
- * **FR-007: Reply failure handling**
28
- * - If posting replies fails for some threads: still remove label
29
- * - Log warnings for each failed reply
30
- * - Partial reply success is acceptable (code changes were pushed successfully)
31
- * - Handler does not throw on reply failures
32
- *
33
- * **SC-006: Thread resolution prevention**
34
- * - Only use `replyToPRComment()` API — never call any thread-resolve API
35
- * - Human reviewer will resolve threads after verifying changes
36
- * - Comments in code explicitly reference SC-006 for clarity
37
- *
38
- * **Structured logging**
39
- * - All operations logged with structured context (prNumber, issueNumber, owner, repo)
40
- * - Error logs include full error messages and relevant context
41
- * - Success/failure states clearly logged for observability
42
- * - All FR/SC references included in log messages for traceability
43
- *
44
- * **Critical operation failures**
45
- * - PR fetch failure: throw (cannot proceed without PR details)
46
- * - Branch switch failure: throw (wrong branch would cause issues)
47
- * - Comment fetch failure: throw (cannot address unknown feedback)
48
- * - Push failure: log error but don't throw (allows retry via label)
27
+ * 7. Commit + push. If CLI failed OR no diff → Disposition B (blocked)
28
+ * 8. Otherwise: for each trusted unresolved thread post one reply targeting
29
+ * the root comment, then call `resolveReviewThread(thread.id)`
30
+ * 9. Strict-decrease success test — R = count of resolves that succeeded.
31
+ * R === 0 Disposition B (blocked); R ≥ 1 → Disposition A (success):
32
+ * warn once per persistently-failed thread, then remove the
33
+ * `waiting-for:address-pr-feedback` label
49
34
  *
50
- * **Non-critical operation failures**
51
- * - No unresolved threads: remove label, early return (success case)
52
- * - Reply posting: log warning, continue (FR-007)
53
- * - Label removal: log warning, continue (non-fatal)
35
+ * Disposition B (blocked, #883): add `blocked:stuck-feedback-loop` and leave
36
+ * `waiting-for:address-pr-feedback` in place. The monitor's pre-enqueue
37
+ * `blocked:*` check keeps the loop paused until an operator removes the
38
+ * label. This ends the runaway "reply-only" cycle (5→10→20→…) observed on
39
+ * christrudelpw/sniplink#4.
54
40
  */
55
41
  export class PrFeedbackHandler {
56
42
  config;
@@ -80,8 +66,15 @@ export class PrFeedbackHandler {
80
66
  const { prNumber } = metadata;
81
67
  const workflowId = `${owner}/${repo}#${issueNumber}`;
82
68
  this.logger.info({ prNumber, issueNumber, owner, repo }, 'Starting PR feedback addressing');
83
- // Create GitHub client scoped to checkout path
69
+ // Create GitHub client scoped to checkout path. Hoisted above the try so
70
+ // the `finally` clear can call it on every exit path (#926 SC-004).
84
71
  const github = createGitHubClient(checkoutPath);
72
+ // #926 SC-004: `agent:in-progress` is cleared structurally at a single
73
+ // shared exit path so no terminal return can leave the label pinned.
74
+ // Idempotency-safe: happy path already coalesces the clear into its own
75
+ // `removeLabels` call; this `finally` is a backstop for the four other
76
+ // exit paths (Cases A/B, both blocked-stuck dispositions, and thrown
77
+ // errors). Non-fatal on failure — mirrors `removeFeedbackLabel` shape.
85
78
  try {
86
79
  // 1. Fetch the PR to get branch name
87
80
  let pr;
@@ -102,28 +95,113 @@ export class PrFeedbackHandler {
102
95
  this.logger.error({ error: String(error), prNumber, branchName, checkoutPath }, 'Failed to switch to PR branch');
103
96
  throw new Error(`Failed to switch to branch ${branchName}: ${String(error)}`);
104
97
  }
105
- // 3. Fetch fresh unresolved review threads
98
+ // 3. Fetch fresh unresolved review threads via GraphQL (#861).
99
+ // REST never populated `.resolved`, so the old filter always emitted []
100
+ // and the handler no-op'd. GraphQL exposes thread-level resolution.
106
101
  let allComments;
102
+ let unresolvedThreadCount;
107
103
  let unresolvedComments;
104
+ let trustedUnresolvedThreads;
105
+ let untrustedSkips;
108
106
  try {
109
- allComments = await github.getPRComments(owner, repo, prNumber);
110
- unresolvedComments = allComments.filter((c) => c.resolved === false);
111
- this.logger.info({ prNumber, totalComments: allComments.length, unresolved: unresolvedComments.length }, 'Fetched PR review comments');
107
+ const threads = await github.getPRReviewThreads(owner, repo, prNumber);
108
+ const unresolvedThreads = threads.filter(t => !t.isResolved);
109
+ allComments = threads.flatMap(t => t.comments);
110
+ unresolvedThreadCount = unresolvedThreads.length;
111
+ // Author-trust gating (#842, #869, #878). Log each skip and drop
112
+ // untrusted comments before the CLI ever sees them. `pr-feedback`
113
+ // surface honors widen-config from .agency/comment-trust.yaml. #878:
114
+ // self-authorship comes from GraphQL's `viewerDidAuthor` field on
115
+ // each comment, not a login-comparison rule threaded via context.
116
+ //
117
+ // #883: also track trusted-unresolved threads (not just comments) so
118
+ // the post-CLI batch can iterate one-reply-per-thread and call
119
+ // `resolveReviewThread` per thread.
120
+ const trustConfig = tryLoadCommentTrustConfig(checkoutPath, this.logger);
121
+ const botLogin = process.env['CLUSTER_GITHUB_USERNAME'] ?? process.env['GH_USERNAME'];
122
+ const trustedUnresolved = [];
123
+ const trustedThreads = [];
124
+ const skips = [];
125
+ for (const thread of unresolvedThreads) {
126
+ let threadHasTrusted = false;
127
+ for (const c of thread.comments) {
128
+ const decision = isTrustedCommentAuthor(c, 'pr-feedback', {
129
+ logger: this.logger,
130
+ ...(botLogin ? { botLogin } : {}),
131
+ ...(trustConfig ? { config: trustConfig } : {}),
132
+ });
133
+ if (decision.trusted) {
134
+ trustedUnresolved.push(c);
135
+ threadHasTrusted = true;
136
+ }
137
+ else {
138
+ skips.push({
139
+ commentId: c.id,
140
+ author: c.author,
141
+ authorAssociation: c.authorAssociation,
142
+ reason: decision.reason,
143
+ viewerDidAuthor: c.viewerDidAuthor,
144
+ });
145
+ this.logger.info({
146
+ event: 'comment-skipped',
147
+ surface: 'pr-feedback',
148
+ commentId: c.id,
149
+ author: c.author,
150
+ authorAssociation: c.authorAssociation,
151
+ reason: decision.reason,
152
+ viewerDidAuthor: c.viewerDidAuthor ?? null,
153
+ }, 'Skipped PR review comment from untrusted author');
154
+ }
155
+ }
156
+ if (threadHasTrusted)
157
+ trustedThreads.push(thread);
158
+ }
159
+ unresolvedComments = trustedUnresolved;
160
+ trustedUnresolvedThreads = trustedThreads;
161
+ untrustedSkips = skips;
162
+ this.logger.info({
163
+ prNumber,
164
+ totalComments: allComments.length,
165
+ unresolvedThreads: unresolvedThreads.length,
166
+ trustedUnresolvedThreads: trustedThreads.length,
167
+ trustedUnresolvedComments: trustedUnresolved.length,
168
+ }, 'Fetched PR review threads (author-trust filtered)');
112
169
  }
113
170
  catch (error) {
114
- this.logger.error({ error: String(error), prNumber, owner, repo }, 'Failed to fetch PR review comments');
115
- throw new Error(`Failed to fetch review comments for PR #${prNumber}: ${String(error)}`);
171
+ this.logger.error({ error: String(error), prNumber, owner, repo }, 'Failed to fetch PR review threads');
172
+ throw new Error(`Failed to fetch review threads for PR #${prNumber}: ${String(error)}`);
116
173
  }
117
- // 4. If no unresolved threads, remove label and return early
118
- if (unresolvedComments.length === 0) {
174
+ // 4. Case A (#869): no unresolved threads at all — remove label and
175
+ // clear dedupe key.
176
+ if (unresolvedThreadCount === 0) {
119
177
  this.logger.info({ prNumber, issueNumber }, 'No unresolved threads found — removing label and exiting');
120
178
  await this.removeFeedbackLabel(github, owner, repo, issueNumber);
121
179
  return;
122
180
  }
181
+ // 4b. Case B (#869 / FR-002): unresolved threads exist, but every
182
+ // comment is untrusted (race-window residue that the monitor didn't
183
+ // catch, or degraded-identity mode). Retain the waiting-for label
184
+ // (FR-002) and clear the dedupe key so the next monitor poll can
185
+ // re-enqueue if the situation changes. Do NOT emit the "No unresolved
186
+ // threads found" log line (SC-002).
187
+ if (unresolvedComments.length === 0) {
188
+ this.logger.warn({
189
+ prNumber, issueNumber, owner, repo,
190
+ totalUnresolvedThreads: unresolvedThreadCount,
191
+ untrustedSkips: untrustedSkips.map((s) => ({
192
+ commentId: s.commentId,
193
+ author: s.author,
194
+ authorAssociation: s.authorAssociation,
195
+ reason: s.reason,
196
+ viewerDidAuthor: s.viewerDidAuthor ?? null,
197
+ })),
198
+ }, 'Zero-trusted unresolved threads — retaining waiting-for:address-pr-feedback label (FR-002)');
199
+ return;
200
+ }
123
201
  // 5. Build structured prompt
124
202
  const prompt = this.buildFeedbackPrompt(unresolvedComments, prNumber, issueNumber);
125
203
  // 6. Spawn Claude CLI to address feedback
126
- const success = await this.spawnClaudeForFeedback(checkoutPath, prompt, workflowId, prNumber);
204
+ const success = await this.spawnClaudeForFeedback(checkoutPath, prompt, workflowId, prNumber, item.workflowName);
127
205
  // 7. Commit and push changes (even on timeout — partial completion strategy)
128
206
  // FR-013: Push partial changes on timeout to preserve work and enable retry
129
207
  let hasChanges = false;
@@ -135,42 +213,92 @@ export class PrFeedbackHandler {
135
213
  }
136
214
  catch (error) {
137
215
  this.logger.error({ error: String(error), prNumber, issueNumber, branchName }, 'Failed to commit and push changes — partial work may be lost');
138
- // Don't throw here — we want to continue with reply attempt and label management
139
- // The label will be kept for retry if success=false
216
+ // Don't throw here — we still need to run the blocked disposition.
217
+ }
218
+ // 7a. Disposition B short-circuit (#883): CLI did not complete cleanly OR
219
+ // there is no diff. Both mean the loop cannot advance on this cycle —
220
+ // add `blocked:stuck-feedback-loop` and leave `waiting-for:*` intact so
221
+ // the operator sees the pause. Do NOT reply, do NOT resolve, do NOT log
222
+ // success.
223
+ if (!success || !hasChanges) {
224
+ this.logger.warn({
225
+ prNumber,
226
+ issueNumber,
227
+ trigger: 'unresolvedThreads>0',
228
+ reason: !success ? 'cli-did-not-complete' : 'no-diff',
229
+ }, 'no-diff cycle — persisting trigger, entering blocked-stuck-feedback-loop disposition');
230
+ await this.addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber);
231
+ return;
140
232
  }
141
- if (!hasChanges && !success) {
142
- this.logger.warn({ prNumber, issueNumber }, 'No changes made and CLI did not complete successfully — will retry');
233
+ // 7b. Happy path — CLI succeeded AND we have a real commit.
234
+ const shortSha = (await this.getHeadShortSha(checkoutPath)) ?? '<unknown>';
235
+ // 8. Interleaved reply→resolve per root thread (#883, Q4-C, FR-005,
236
+ // FR-007). Input-set closure: iterate `trustedUnresolvedThreads`
237
+ // captured at cycle start.
238
+ const outcomes = [];
239
+ for (const thread of trustedUnresolvedThreads) {
240
+ const replyBody = `Addressed in ${shortSha} — please review, and re-open this thread if it still falls short.`;
241
+ const replyResult = await this.tryPostReply(github, owner, repo, prNumber, thread.rootCommentId, replyBody);
242
+ const resolveResult = await this.tryResolveReviewThread(github, thread.id);
243
+ outcomes.push({
244
+ threadId: thread.id,
245
+ rootCommentId: thread.rootCommentId,
246
+ replyResult,
247
+ resolveResult,
248
+ });
143
249
  }
144
- // 8. Reply to review threads (only if CLI completed successfully)
145
- // FR-007: Partial reply failure is acceptable — we still remove the label
146
- if (success) {
147
- try {
148
- await this.replyToThreads(github, owner, repo, prNumber, unresolvedComments);
149
- }
150
- catch (error) {
151
- // FR-007: Even if reply posting fails, we consider the task complete
152
- // and remove the label. The code changes were pushed successfully.
153
- this.logger.warn({ error: String(error), prNumber, issueNumber }, 'Failed to post some or all thread replies — partial success acceptable');
154
- // Continue to label removal
155
- }
250
+ // 9. Strict-decrease success test (#883, FR-006, FR-010).
251
+ const resolveSuccesses = outcomes.filter(o => o.resolveResult.ok).length;
252
+ const resolveFailures = outcomes.filter(o => !o.resolveResult.ok);
253
+ if (resolveSuccesses === 0) {
254
+ // FR-006 tail commit landed but no thread transitioned.
255
+ this.logger.warn({ prNumber, issueNumber, outcomes }, 'commit pushed but resolve batch had zero successes — persisting trigger, entering blocked-stuck-feedback-loop disposition');
256
+ await this.addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber);
257
+ return;
156
258
  }
157
- else {
158
- this.logger.warn({ prNumber, issueNumber }, 'Skipping thread replies due to CLI timeout or failure will retry on next cycle');
259
+ // FR-010: one warn per persistently-failed thread, emitted BEFORE
260
+ // clearing the label so a silent partial failure is impossible.
261
+ for (const f of resolveFailures) {
262
+ this.logger.warn({
263
+ prNumber, issueNumber, owner, repo,
264
+ threadId: f.threadId,
265
+ rootCommentId: f.rootCommentId,
266
+ error: f.resolveResult.error,
267
+ remedy: 'Resolve the thread manually in the GitHub UI — the reply is already on the thread',
268
+ }, 'resolveReviewThread persistently failed after retries; label will still be cleared');
159
269
  }
160
- // 9. Remove label (only if successful, otherwise keep for retry)
161
- // FR-013: Keep label on timeout/failure to enable retry
162
- if (success) {
163
- await this.removeFeedbackLabel(github, owner, repo, issueNumber);
164
- this.logger.info({ prNumber, issueNumber }, 'PR feedback addressing completed successfully');
270
+ // 10. Label-clear LAST (Q4 tail). #926 FR-006: coalesce the happy-path
271
+ // clear into a single `removeLabels` call so `waiting-for:*` and
272
+ // `agent:in-progress` disappear in one request — no intermediate state
273
+ // where cockpit / auto observers see one label without the other.
274
+ // The `finally` clear becomes a no-op on this path (idempotent remove).
275
+ try {
276
+ await github.removeLabels(owner, repo, issueNumber, [
277
+ WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL,
278
+ AGENT_IN_PROGRESS_LABEL,
279
+ ]);
280
+ this.logger.info({ issueNumber }, 'Removed waiting-for:address-pr-feedback + agent:in-progress labels (coalesced)');
165
281
  }
166
- else {
167
- this.logger.info({ prNumber, issueNumber, hasChanges }, 'Keeping waiting-for:address-pr-feedback label for retry (CLI did not complete successfully)');
282
+ catch (error) {
283
+ this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove happy-path labels non-fatal, finally will re-attempt in-progress clear');
168
284
  }
285
+ this.logger.info({
286
+ prNumber, issueNumber,
287
+ resolveSuccesses,
288
+ resolveFailures: resolveFailures.length,
289
+ shortSha,
290
+ }, 'PR feedback cycle succeeded (strict decrease met)');
169
291
  }
170
292
  catch (error) {
171
293
  this.logger.error({ error: String(error), prNumber, issueNumber, owner, repo }, 'Error processing PR feedback — task failed');
172
294
  throw error;
173
295
  }
296
+ finally {
297
+ // #926 SC-004: structural single-point clear. Every terminal exit
298
+ // (Case A, Case B, both blocked-stuck dispositions, happy path, and
299
+ // thrown errors) flows through here. Non-fatal on failure.
300
+ await this.clearInProgressLabel(github, owner, repo, issueNumber);
301
+ }
174
302
  }
175
303
  /**
176
304
  * Build a structured prompt containing all unresolved review comments.
@@ -190,11 +318,13 @@ export class PrFeedbackHandler {
190
318
  return `${idx + 1}. **${c.author}** (${location}):\n ${c.body}`;
191
319
  })
192
320
  .join('\n\n');
321
+ // #842: fence ingested thread content (author-trust filtered upstream).
322
+ const fenced = wrapUntrustedData(commentList, `PR #${prNumber} review comments`);
193
323
  return `You are addressing PR review feedback for PR #${prNumber} (linked to issue #${issueNumber}).
194
324
 
195
325
  The following unresolved review comments need to be addressed:
196
326
 
197
- ${commentList}
327
+ ${fenced}
198
328
 
199
329
  **Instructions:**
200
330
  1. Read and understand each review comment above
@@ -210,8 +340,12 @@ Please proceed with addressing the feedback.`;
210
340
  * Returns true if the CLI completed successfully, false on timeout or failure.
211
341
  * FR-013: On timeout, partial changes are pushed and label is kept for retry.
212
342
  */
213
- async spawnClaudeForFeedback(checkoutPath, prompt, workflowId, prNumber) {
214
- this.logger.info({ cwd: checkoutPath, timeoutMs: this.config.phaseTimeoutMs }, 'Spawning Claude CLI for PR feedback');
343
+ async spawnClaudeForFeedback(checkoutPath, prompt, workflowId, prNumber, workflowName) {
344
+ // #814 / Q1→B: pr-feedback resolves `{ provider, model }` against the
345
+ // `implement` phase — pr-feedback revises the code `implement` produced,
346
+ // so the same agent/model that wrote the code should address review on it.
347
+ const { provider, model } = resolveAgentForPhase(this.config, workflowName, 'implement');
348
+ this.logger.info({ cwd: checkoutPath, timeoutMs: this.config.phaseTimeoutMs, provider, model }, 'Spawning Claude CLI for PR feedback');
215
349
  let child;
216
350
  try {
217
351
  const handle = await this.agentLauncher.launch({
@@ -219,10 +353,12 @@ Please proceed with addressing the feedback.`;
219
353
  kind: 'pr-feedback',
220
354
  prNumber,
221
355
  prompt,
356
+ ...(model !== undefined ? { model } : {}),
222
357
  },
223
358
  cwd: checkoutPath,
224
359
  env: {},
225
360
  credentials: buildLaunchCredentials(this.config.credentialRole),
361
+ provider,
226
362
  });
227
363
  child = handle.process;
228
364
  }
@@ -343,50 +479,92 @@ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`;
343
479
  return true;
344
480
  }
345
481
  /**
346
- * Post a reply to each unresolved review thread.
347
- *
348
- * SC-006: Never resolves threads that's left to the human reviewer.
349
- * FR-007: Partial failure is acceptable: if some replies fail, log warnings but continue.
350
- * The handler will still remove the label even if some replies fail.
482
+ * Post a single reply targeting the root comment of a review thread.
483
+ * Returns a discriminated result — failures do not throw so the caller
484
+ * (#883 per-thread outcome loop) can aggregate results.
351
485
  */
352
- async replyToThreads(github, owner, repo, prNumber, comments) {
353
- this.logger.info({ prNumber, threadCount: comments.length }, 'Posting replies to review threads');
354
- let successCount = 0;
355
- let failureCount = 0;
356
- for (const comment of comments) {
357
- try {
358
- // SC-006: Only reply, never resolve — human reviewer will resolve after verification
359
- const replyBody = `I've addressed this feedback in the latest commit. Please review the changes.`;
360
- await github.replyToPRComment(owner, repo, prNumber, comment.id, replyBody);
361
- this.logger.debug({ prNumber, commentId: comment.id, path: comment.path, line: comment.line }, 'Posted reply to review thread');
362
- successCount++;
363
- }
364
- catch (error) {
365
- // FR-007: Continue processing other threads even if this one fails
366
- failureCount++;
367
- this.logger.warn({ error: String(error), prNumber, commentId: comment.id, path: comment.path, line: comment.line }, 'Failed to post reply to review thread — continuing with remaining threads');
368
- }
486
+ async tryPostReply(github, owner, repo, prNumber, rootCommentId, body) {
487
+ try {
488
+ await github.replyToPRComment(owner, repo, prNumber, rootCommentId, body);
489
+ return { ok: true };
490
+ }
491
+ catch (error) {
492
+ return { ok: false, error: String(error) };
369
493
  }
370
- if (failureCount > 0) {
371
- this.logger.warn({ prNumber, successCount, failureCount, total: comments.length }, 'Some thread replies failed — partial success (acceptable per FR-007)');
494
+ }
495
+ /**
496
+ * Delegate to `github.resolveReviewThread` (which owns the 3× retry). Wraps
497
+ * the result in a discriminated union so the caller can aggregate outcomes
498
+ * without a try/catch (#883).
499
+ */
500
+ async tryResolveReviewThread(github, threadId) {
501
+ try {
502
+ await github.resolveReviewThread(threadId);
503
+ return { ok: true };
504
+ }
505
+ catch (error) {
506
+ return { ok: false, error: String(error) };
507
+ }
508
+ }
509
+ /**
510
+ * Read the short SHA of the just-pushed HEAD commit. Returns null when the
511
+ * git command fails; caller falls back to `<unknown>` in the reply body —
512
+ * the SHA is decoration, not termination logic (#883).
513
+ */
514
+ async getHeadShortSha(checkoutPath) {
515
+ try {
516
+ const result = await executeCommand('git', ['rev-parse', '--short', 'HEAD'], { cwd: checkoutPath });
517
+ if (result.exitCode !== 0)
518
+ return null;
519
+ const sha = result.stdout.trim();
520
+ return sha.length > 0 ? sha : null;
372
521
  }
373
- else {
374
- this.logger.info({ prNumber, successCount, total: comments.length }, 'Successfully posted all thread replies');
522
+ catch {
523
+ return null;
524
+ }
525
+ }
526
+ /**
527
+ * Add the `blocked:stuck-feedback-loop` label to signal that the fix cycle
528
+ * cannot advance and must not be re-enqueued until the operator removes the
529
+ * label. Non-fatal on failure — leaving `waiting-for:*` in place is the
530
+ * fallback safety net (#883).
531
+ */
532
+ async addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber) {
533
+ try {
534
+ await github.addLabels(owner, repo, issueNumber, [BLOCKED_STUCK_FEEDBACK_LOOP_LABEL]);
535
+ this.logger.info({ issueNumber, label: BLOCKED_STUCK_FEEDBACK_LOOP_LABEL }, 'Added blocked:stuck-feedback-loop label');
536
+ }
537
+ catch (error) {
538
+ this.logger.warn({ error: String(error), issueNumber, label: BLOCKED_STUCK_FEEDBACK_LOOP_LABEL }, 'Failed to add blocked:stuck-feedback-loop label — non-fatal, waiting-for label persists');
375
539
  }
376
- // FR-007: Even if all replies fail, we don't throw — the caller will still remove the label
377
- // because the code changes were pushed successfully
378
540
  }
379
541
  /**
380
542
  * Remove the `waiting-for:address-pr-feedback` label from the linked issue.
381
543
  */
382
544
  async removeFeedbackLabel(github, owner, repo, issueNumber) {
383
545
  try {
384
- await github.removeLabels(owner, repo, issueNumber, ['waiting-for:address-pr-feedback']);
546
+ await github.removeLabels(owner, repo, issueNumber, [WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL]);
385
547
  this.logger.info({ issueNumber }, 'Removed waiting-for:address-pr-feedback label');
386
548
  }
387
549
  catch (error) {
388
550
  this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove waiting-for:address-pr-feedback label — non-fatal');
389
551
  }
390
552
  }
553
+ /**
554
+ * Structural clear of `agent:in-progress` on the linked issue (#926 SC-004).
555
+ * Called from the shared `finally` block in `handle()` — runs on every
556
+ * terminal exit path (Cases A/B, both blocked-stuck dispositions, happy
557
+ * path, and thrown errors). Idempotent: GitHub's `removeLabels` is a no-op
558
+ * when the label is already absent, so the happy-path coalesced removal +
559
+ * this `finally` clear together produce at most one truthful post-state.
560
+ */
561
+ async clearInProgressLabel(github, owner, repo, issueNumber) {
562
+ try {
563
+ await github.removeLabels(owner, repo, issueNumber, [AGENT_IN_PROGRESS_LABEL]);
564
+ }
565
+ catch (error) {
566
+ this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove agent:in-progress label — non-fatal');
567
+ }
568
+ }
391
569
  }
392
570
  //# sourceMappingURL=pr-feedback-handler.js.map