@generacy-ai/orchestrator 0.6.0 → 0.7.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 (178) hide show
  1. package/dist/config/schema.d.ts +7 -7
  2. package/dist/index.d.ts +3 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +4 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/routes/health.d.ts.map +1 -1
  7. package/dist/routes/health.js +5 -0
  8. package/dist/routes/health.js.map +1 -1
  9. package/dist/server.d.ts.map +1 -1
  10. package/dist/server.js +130 -26
  11. package/dist/server.js.map +1 -1
  12. package/dist/services/base-advance-monitor-service.d.ts +78 -0
  13. package/dist/services/base-advance-monitor-service.d.ts.map +1 -0
  14. package/dist/services/base-advance-monitor-service.js +233 -0
  15. package/dist/services/base-advance-monitor-service.js.map +1 -0
  16. package/dist/services/boot-resume-service.d.ts +19 -0
  17. package/dist/services/boot-resume-service.d.ts.map +1 -0
  18. package/dist/services/boot-resume-service.js +87 -0
  19. package/dist/services/boot-resume-service.js.map +1 -0
  20. package/dist/services/epic-completion-monitor-service.d.ts.map +1 -1
  21. package/dist/services/epic-completion-monitor-service.js +2 -0
  22. package/dist/services/epic-completion-monitor-service.js.map +1 -1
  23. package/dist/services/in-memory-queue-adapter.d.ts +4 -0
  24. package/dist/services/in-memory-queue-adapter.d.ts.map +1 -1
  25. package/dist/services/in-memory-queue-adapter.js +30 -3
  26. package/dist/services/in-memory-queue-adapter.js.map +1 -1
  27. package/dist/services/label-monitor-service.d.ts +3 -3
  28. package/dist/services/label-monitor-service.d.ts.map +1 -1
  29. package/dist/services/label-monitor-service.js +58 -41
  30. package/dist/services/label-monitor-service.js.map +1 -1
  31. package/dist/services/label-sync-service.d.ts.map +1 -1
  32. package/dist/services/label-sync-service.js +45 -10
  33. package/dist/services/label-sync-service.js.map +1 -1
  34. package/dist/services/merge-conflict-monitor-service.d.ts +73 -0
  35. package/dist/services/merge-conflict-monitor-service.d.ts.map +1 -0
  36. package/dist/services/merge-conflict-monitor-service.js +295 -0
  37. package/dist/services/merge-conflict-monitor-service.js.map +1 -0
  38. package/dist/services/orchestrator-version.d.ts +2 -0
  39. package/dist/services/orchestrator-version.d.ts.map +1 -0
  40. package/dist/services/orchestrator-version.js +23 -0
  41. package/dist/services/orchestrator-version.js.map +1 -0
  42. package/dist/services/phase-tracker-service.d.ts +10 -0
  43. package/dist/services/phase-tracker-service.d.ts.map +1 -1
  44. package/dist/services/phase-tracker-service.js +38 -0
  45. package/dist/services/phase-tracker-service.js.map +1 -1
  46. package/dist/services/post-activation-dispatch.d.ts +18 -0
  47. package/dist/services/post-activation-dispatch.d.ts.map +1 -0
  48. package/dist/services/post-activation-dispatch.js +30 -0
  49. package/dist/services/post-activation-dispatch.js.map +1 -0
  50. package/dist/services/pr-feedback-monitor-service.d.ts +13 -4
  51. package/dist/services/pr-feedback-monitor-service.d.ts.map +1 -1
  52. package/dist/services/pr-feedback-monitor-service.js +212 -26
  53. package/dist/services/pr-feedback-monitor-service.js.map +1 -1
  54. package/dist/services/redis-queue-adapter.d.ts +4 -0
  55. package/dist/services/redis-queue-adapter.d.ts.map +1 -1
  56. package/dist/services/redis-queue-adapter.js +90 -10
  57. package/dist/services/redis-queue-adapter.js.map +1 -1
  58. package/dist/services/worker-dispatcher.d.ts +14 -2
  59. package/dist/services/worker-dispatcher.d.ts.map +1 -1
  60. package/dist/services/worker-dispatcher.js +55 -2
  61. package/dist/services/worker-dispatcher.js.map +1 -1
  62. package/dist/types/api.d.ts +3 -0
  63. package/dist/types/api.d.ts.map +1 -1
  64. package/dist/types/api.js +1 -0
  65. package/dist/types/api.js.map +1 -1
  66. package/dist/types/index.d.ts +1 -1
  67. package/dist/types/index.d.ts.map +1 -1
  68. package/dist/types/index.js.map +1 -1
  69. package/dist/types/monitor.d.ts +72 -4
  70. package/dist/types/monitor.d.ts.map +1 -1
  71. package/dist/worker/base-merge.d.ts +54 -0
  72. package/dist/worker/base-merge.d.ts.map +1 -0
  73. package/dist/worker/base-merge.js +99 -0
  74. package/dist/worker/base-merge.js.map +1 -0
  75. package/dist/worker/clarification-markers.d.ts +29 -0
  76. package/dist/worker/clarification-markers.d.ts.map +1 -0
  77. package/dist/worker/clarification-markers.js +44 -0
  78. package/dist/worker/clarification-markers.js.map +1 -0
  79. package/dist/worker/clarification-poster.d.ts.map +1 -1
  80. package/dist/worker/clarification-poster.js +266 -25
  81. package/dist/worker/clarification-poster.js.map +1 -1
  82. package/dist/worker/claude-cli-worker.d.ts +21 -2
  83. package/dist/worker/claude-cli-worker.d.ts.map +1 -1
  84. package/dist/worker/claude-cli-worker.js +149 -4
  85. package/dist/worker/claude-cli-worker.js.map +1 -1
  86. package/dist/worker/cli-spawner.d.ts +4 -0
  87. package/dist/worker/cli-spawner.d.ts.map +1 -1
  88. package/dist/worker/cli-spawner.js +51 -7
  89. package/dist/worker/cli-spawner.js.map +1 -1
  90. package/dist/worker/config.d.ts +24 -8
  91. package/dist/worker/config.d.ts.map +1 -1
  92. package/dist/worker/config.js +37 -2
  93. package/dist/worker/config.js.map +1 -1
  94. package/dist/worker/evidence-hash.d.ts +32 -0
  95. package/dist/worker/evidence-hash.d.ts.map +1 -0
  96. package/dist/worker/evidence-hash.js +139 -0
  97. package/dist/worker/evidence-hash.js.map +1 -0
  98. package/dist/worker/handler-outcome-assertion.d.ts +38 -0
  99. package/dist/worker/handler-outcome-assertion.d.ts.map +1 -0
  100. package/dist/worker/handler-outcome-assertion.js +58 -0
  101. package/dist/worker/handler-outcome-assertion.js.map +1 -0
  102. package/dist/worker/handler-outcome.d.ts +50 -0
  103. package/dist/worker/handler-outcome.d.ts.map +1 -0
  104. package/dist/worker/handler-outcome.js +2 -0
  105. package/dist/worker/handler-outcome.js.map +1 -0
  106. package/dist/worker/label-manager.d.ts +58 -2
  107. package/dist/worker/label-manager.d.ts.map +1 -1
  108. package/dist/worker/label-manager.js +225 -27
  109. package/dist/worker/label-manager.js.map +1 -1
  110. package/dist/worker/merge-conflict-handler.d.ts +141 -0
  111. package/dist/worker/merge-conflict-handler.d.ts.map +1 -0
  112. package/dist/worker/merge-conflict-handler.js +642 -0
  113. package/dist/worker/merge-conflict-handler.js.map +1 -0
  114. package/dist/worker/merge-conflict-prompt.d.ts +35 -0
  115. package/dist/worker/merge-conflict-prompt.d.ts.map +1 -0
  116. package/dist/worker/merge-conflict-prompt.js +71 -0
  117. package/dist/worker/merge-conflict-prompt.js.map +1 -0
  118. package/dist/worker/merge-conflict-remedy.d.ts +42 -0
  119. package/dist/worker/merge-conflict-remedy.d.ts.map +1 -0
  120. package/dist/worker/merge-conflict-remedy.js +28 -0
  121. package/dist/worker/merge-conflict-remedy.js.map +1 -0
  122. package/dist/worker/output-tail-synthesis.d.ts +15 -0
  123. package/dist/worker/output-tail-synthesis.d.ts.map +1 -0
  124. package/dist/worker/output-tail-synthesis.js +25 -0
  125. package/dist/worker/output-tail-synthesis.js.map +1 -0
  126. package/dist/worker/output-tail.d.ts +13 -0
  127. package/dist/worker/output-tail.d.ts.map +1 -0
  128. package/dist/worker/output-tail.js +31 -0
  129. package/dist/worker/output-tail.js.map +1 -0
  130. package/dist/worker/pause-context.d.ts +30 -0
  131. package/dist/worker/pause-context.d.ts.map +1 -0
  132. package/dist/worker/pause-context.js +98 -0
  133. package/dist/worker/pause-context.js.map +1 -0
  134. package/dist/worker/phase-loop.d.ts +66 -0
  135. package/dist/worker/phase-loop.d.ts.map +1 -1
  136. package/dist/worker/phase-loop.js +295 -29
  137. package/dist/worker/phase-loop.js.map +1 -1
  138. package/dist/worker/pr-feedback-handler.d.ts +46 -46
  139. package/dist/worker/pr-feedback-handler.d.ts.map +1 -1
  140. package/dist/worker/pr-feedback-handler.js +276 -105
  141. package/dist/worker/pr-feedback-handler.js.map +1 -1
  142. package/dist/worker/pr-manager.d.ts +5 -0
  143. package/dist/worker/pr-manager.d.ts.map +1 -1
  144. package/dist/worker/pr-manager.js +7 -0
  145. package/dist/worker/pr-manager.js.map +1 -1
  146. package/dist/worker/product-diff.d.ts +39 -0
  147. package/dist/worker/product-diff.d.ts.map +1 -0
  148. package/dist/worker/product-diff.js +43 -0
  149. package/dist/worker/product-diff.js.map +1 -0
  150. package/dist/worker/provisioning-failure.d.ts +14 -0
  151. package/dist/worker/provisioning-failure.d.ts.map +1 -0
  152. package/dist/worker/provisioning-failure.js +2 -0
  153. package/dist/worker/provisioning-failure.js.map +1 -0
  154. package/dist/worker/repo-checkout.d.ts +14 -0
  155. package/dist/worker/repo-checkout.d.ts.map +1 -1
  156. package/dist/worker/repo-checkout.js +19 -1
  157. package/dist/worker/repo-checkout.js.map +1 -1
  158. package/dist/worker/stage-comment-manager.d.ts +42 -1
  159. package/dist/worker/stage-comment-manager.d.ts.map +1 -1
  160. package/dist/worker/stage-comment-manager.js +205 -1
  161. package/dist/worker/stage-comment-manager.js.map +1 -1
  162. package/dist/worker/terminal-label-op-error.d.ts +23 -0
  163. package/dist/worker/terminal-label-op-error.d.ts.map +1 -0
  164. package/dist/worker/terminal-label-op-error.js +27 -0
  165. package/dist/worker/terminal-label-op-error.js.map +1 -0
  166. package/dist/worker/types.d.ts +161 -2
  167. package/dist/worker/types.d.ts.map +1 -1
  168. package/dist/worker/types.js +11 -0
  169. package/dist/worker/types.js.map +1 -1
  170. package/dist/worker/validate-fix-handler.d.ts +79 -0
  171. package/dist/worker/validate-fix-handler.d.ts.map +1 -0
  172. package/dist/worker/validate-fix-handler.js +320 -0
  173. package/dist/worker/validate-fix-handler.js.map +1 -0
  174. package/dist/worker/worker-result.d.ts +39 -0
  175. package/dist/worker/worker-result.d.ts.map +1 -0
  176. package/dist/worker/worker-result.js +10 -0
  177. package/dist/worker/worker-result.js.map +1 -0
  178. package/package.json +6 -5
@@ -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;AAChD,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;IA6UlE;;;;;;;;OAQG;IACH,OAAO,CAAC,mBAAmB;IAgC3B;;;;;OAKG;YACW,sBAAsB;IA0HpC;;;;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,18 @@
1
- import { createGitHubClient } from '@generacy-ai/workflow-engine';
1
+ import { createGitHubClient, executeCommand, isTrustedCommentAuthor, tryLoadCommentTrustConfig, wrapUntrustedData, } from '@generacy-ai/workflow-engine';
2
2
  import { OutputCapture } from './output-capture.js';
3
3
  import { RepoCheckout } from './repo-checkout.js';
4
4
  import { buildLaunchCredentials } from './credentials-helper.js';
5
+ /** Label added by the handler when the fix cycle cannot advance (#883). */
6
+ const BLOCKED_STUCK_FEEDBACK_LOOP_LABEL = 'blocked:stuck-feedback-loop';
7
+ /**
8
+ * `agent:in-progress` label — cleared structurally at the single shared exit
9
+ * path (#926 SC-004/SC-005). Extracted to a module constant so the literal
10
+ * appears at exactly one code site: the coalesced happy-path removal and the
11
+ * `clearInProgressLabel` fallback both reference this constant.
12
+ */
13
+ const AGENT_IN_PROGRESS_LABEL = 'agent:in-progress';
14
+ /** Waiting gate cleared alongside `agent:in-progress` on the happy path. */
15
+ const WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL = 'waiting-for:address-pr-feedback';
5
16
  /**
6
17
  * Handles the `address-pr-feedback` command.
7
18
  *
@@ -9,48 +20,22 @@ import { buildLaunchCredentials } from './credentials-helper.js';
9
20
  * 1. Extract PR number from queue item metadata
10
21
  * 2. Fetch the PR to get the branch name
11
22
  * 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
23
+ * 4. Fetch fresh unresolved review threads (trust-filtered per-thread)
24
+ * 5. Build a structured prompt with all trusted unresolved comments
14
25
  * 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)
26
+ * 7. Commit + push. If CLI failed OR no diff → Disposition B (blocked)
27
+ * 8. Otherwise: for each trusted unresolved thread post one reply targeting
28
+ * the root comment, then call `resolveReviewThread(thread.id)`
29
+ * 9. Strict-decrease success test — R = count of resolves that succeeded.
30
+ * R === 0 Disposition B (blocked); R ≥ 1 → Disposition A (success):
31
+ * warn once per persistently-failed thread, then remove the
32
+ * `waiting-for:address-pr-feedback` label
49
33
  *
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)
34
+ * Disposition B (blocked, #883): add `blocked:stuck-feedback-loop` and leave
35
+ * `waiting-for:address-pr-feedback` in place. The monitor's pre-enqueue
36
+ * `blocked:*` check keeps the loop paused until an operator removes the
37
+ * label. This ends the runaway "reply-only" cycle (5→10→20→…) observed on
38
+ * christrudelpw/sniplink#4.
54
39
  */
55
40
  export class PrFeedbackHandler {
56
41
  config;
@@ -80,8 +65,15 @@ export class PrFeedbackHandler {
80
65
  const { prNumber } = metadata;
81
66
  const workflowId = `${owner}/${repo}#${issueNumber}`;
82
67
  this.logger.info({ prNumber, issueNumber, owner, repo }, 'Starting PR feedback addressing');
83
- // Create GitHub client scoped to checkout path
68
+ // Create GitHub client scoped to checkout path. Hoisted above the try so
69
+ // the `finally` clear can call it on every exit path (#926 SC-004).
84
70
  const github = createGitHubClient(checkoutPath);
71
+ // #926 SC-004: `agent:in-progress` is cleared structurally at a single
72
+ // shared exit path so no terminal return can leave the label pinned.
73
+ // Idempotency-safe: happy path already coalesces the clear into its own
74
+ // `removeLabels` call; this `finally` is a backstop for the four other
75
+ // exit paths (Cases A/B, both blocked-stuck dispositions, and thrown
76
+ // errors). Non-fatal on failure — mirrors `removeFeedbackLabel` shape.
85
77
  try {
86
78
  // 1. Fetch the PR to get branch name
87
79
  let pr;
@@ -102,24 +94,109 @@ export class PrFeedbackHandler {
102
94
  this.logger.error({ error: String(error), prNumber, branchName, checkoutPath }, 'Failed to switch to PR branch');
103
95
  throw new Error(`Failed to switch to branch ${branchName}: ${String(error)}`);
104
96
  }
105
- // 3. Fetch fresh unresolved review threads
97
+ // 3. Fetch fresh unresolved review threads via GraphQL (#861).
98
+ // REST never populated `.resolved`, so the old filter always emitted []
99
+ // and the handler no-op'd. GraphQL exposes thread-level resolution.
106
100
  let allComments;
101
+ let unresolvedThreadCount;
107
102
  let unresolvedComments;
103
+ let trustedUnresolvedThreads;
104
+ let untrustedSkips;
108
105
  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');
106
+ const threads = await github.getPRReviewThreads(owner, repo, prNumber);
107
+ const unresolvedThreads = threads.filter(t => !t.isResolved);
108
+ allComments = threads.flatMap(t => t.comments);
109
+ unresolvedThreadCount = unresolvedThreads.length;
110
+ // Author-trust gating (#842, #869, #878). Log each skip and drop
111
+ // untrusted comments before the CLI ever sees them. `pr-feedback`
112
+ // surface honors widen-config from .agency/comment-trust.yaml. #878:
113
+ // self-authorship comes from GraphQL's `viewerDidAuthor` field on
114
+ // each comment, not a login-comparison rule threaded via context.
115
+ //
116
+ // #883: also track trusted-unresolved threads (not just comments) so
117
+ // the post-CLI batch can iterate one-reply-per-thread and call
118
+ // `resolveReviewThread` per thread.
119
+ const trustConfig = tryLoadCommentTrustConfig(checkoutPath, this.logger);
120
+ const botLogin = process.env['CLUSTER_GITHUB_USERNAME'] ?? process.env['GH_USERNAME'];
121
+ const trustedUnresolved = [];
122
+ const trustedThreads = [];
123
+ const skips = [];
124
+ for (const thread of unresolvedThreads) {
125
+ let threadHasTrusted = false;
126
+ for (const c of thread.comments) {
127
+ const decision = isTrustedCommentAuthor(c, 'pr-feedback', {
128
+ logger: this.logger,
129
+ ...(botLogin ? { botLogin } : {}),
130
+ ...(trustConfig ? { config: trustConfig } : {}),
131
+ });
132
+ if (decision.trusted) {
133
+ trustedUnresolved.push(c);
134
+ threadHasTrusted = true;
135
+ }
136
+ else {
137
+ skips.push({
138
+ commentId: c.id,
139
+ author: c.author,
140
+ authorAssociation: c.authorAssociation,
141
+ reason: decision.reason,
142
+ viewerDidAuthor: c.viewerDidAuthor,
143
+ });
144
+ this.logger.info({
145
+ event: 'comment-skipped',
146
+ surface: 'pr-feedback',
147
+ commentId: c.id,
148
+ author: c.author,
149
+ authorAssociation: c.authorAssociation,
150
+ reason: decision.reason,
151
+ viewerDidAuthor: c.viewerDidAuthor ?? null,
152
+ }, 'Skipped PR review comment from untrusted author');
153
+ }
154
+ }
155
+ if (threadHasTrusted)
156
+ trustedThreads.push(thread);
157
+ }
158
+ unresolvedComments = trustedUnresolved;
159
+ trustedUnresolvedThreads = trustedThreads;
160
+ untrustedSkips = skips;
161
+ this.logger.info({
162
+ prNumber,
163
+ totalComments: allComments.length,
164
+ unresolvedThreads: unresolvedThreads.length,
165
+ trustedUnresolvedThreads: trustedThreads.length,
166
+ trustedUnresolvedComments: trustedUnresolved.length,
167
+ }, 'Fetched PR review threads (author-trust filtered)');
112
168
  }
113
169
  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)}`);
170
+ this.logger.error({ error: String(error), prNumber, owner, repo }, 'Failed to fetch PR review threads');
171
+ throw new Error(`Failed to fetch review threads for PR #${prNumber}: ${String(error)}`);
116
172
  }
117
- // 4. If no unresolved threads, remove label and return early
118
- if (unresolvedComments.length === 0) {
173
+ // 4. Case A (#869): no unresolved threads at all — remove label and
174
+ // clear dedupe key.
175
+ if (unresolvedThreadCount === 0) {
119
176
  this.logger.info({ prNumber, issueNumber }, 'No unresolved threads found — removing label and exiting');
120
177
  await this.removeFeedbackLabel(github, owner, repo, issueNumber);
121
178
  return;
122
179
  }
180
+ // 4b. Case B (#869 / FR-002): unresolved threads exist, but every
181
+ // comment is untrusted (race-window residue that the monitor didn't
182
+ // catch, or degraded-identity mode). Retain the waiting-for label
183
+ // (FR-002) and clear the dedupe key so the next monitor poll can
184
+ // re-enqueue if the situation changes. Do NOT emit the "No unresolved
185
+ // threads found" log line (SC-002).
186
+ if (unresolvedComments.length === 0) {
187
+ this.logger.warn({
188
+ prNumber, issueNumber, owner, repo,
189
+ totalUnresolvedThreads: unresolvedThreadCount,
190
+ untrustedSkips: untrustedSkips.map((s) => ({
191
+ commentId: s.commentId,
192
+ author: s.author,
193
+ authorAssociation: s.authorAssociation,
194
+ reason: s.reason,
195
+ viewerDidAuthor: s.viewerDidAuthor ?? null,
196
+ })),
197
+ }, 'Zero-trusted unresolved threads — retaining waiting-for:address-pr-feedback label (FR-002)');
198
+ return;
199
+ }
123
200
  // 5. Build structured prompt
124
201
  const prompt = this.buildFeedbackPrompt(unresolvedComments, prNumber, issueNumber);
125
202
  // 6. Spawn Claude CLI to address feedback
@@ -135,42 +212,92 @@ export class PrFeedbackHandler {
135
212
  }
136
213
  catch (error) {
137
214
  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
215
+ // Don't throw here — we still need to run the blocked disposition.
216
+ }
217
+ // 7a. Disposition B short-circuit (#883): CLI did not complete cleanly OR
218
+ // there is no diff. Both mean the loop cannot advance on this cycle —
219
+ // add `blocked:stuck-feedback-loop` and leave `waiting-for:*` intact so
220
+ // the operator sees the pause. Do NOT reply, do NOT resolve, do NOT log
221
+ // success.
222
+ if (!success || !hasChanges) {
223
+ this.logger.warn({
224
+ prNumber,
225
+ issueNumber,
226
+ trigger: 'unresolvedThreads>0',
227
+ reason: !success ? 'cli-did-not-complete' : 'no-diff',
228
+ }, 'no-diff cycle — persisting trigger, entering blocked-stuck-feedback-loop disposition');
229
+ await this.addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber);
230
+ return;
140
231
  }
141
- if (!hasChanges && !success) {
142
- this.logger.warn({ prNumber, issueNumber }, 'No changes made and CLI did not complete successfully — will retry');
232
+ // 7b. Happy path — CLI succeeded AND we have a real commit.
233
+ const shortSha = (await this.getHeadShortSha(checkoutPath)) ?? '<unknown>';
234
+ // 8. Interleaved reply→resolve per root thread (#883, Q4-C, FR-005,
235
+ // FR-007). Input-set closure: iterate `trustedUnresolvedThreads`
236
+ // captured at cycle start.
237
+ const outcomes = [];
238
+ for (const thread of trustedUnresolvedThreads) {
239
+ const replyBody = `Addressed in ${shortSha} — please review, and re-open this thread if it still falls short.`;
240
+ const replyResult = await this.tryPostReply(github, owner, repo, prNumber, thread.rootCommentId, replyBody);
241
+ const resolveResult = await this.tryResolveReviewThread(github, thread.id);
242
+ outcomes.push({
243
+ threadId: thread.id,
244
+ rootCommentId: thread.rootCommentId,
245
+ replyResult,
246
+ resolveResult,
247
+ });
143
248
  }
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
- }
249
+ // 9. Strict-decrease success test (#883, FR-006, FR-010).
250
+ const resolveSuccesses = outcomes.filter(o => o.resolveResult.ok).length;
251
+ const resolveFailures = outcomes.filter(o => !o.resolveResult.ok);
252
+ if (resolveSuccesses === 0) {
253
+ // FR-006 tail commit landed but no thread transitioned.
254
+ this.logger.warn({ prNumber, issueNumber, outcomes }, 'commit pushed but resolve batch had zero successes — persisting trigger, entering blocked-stuck-feedback-loop disposition');
255
+ await this.addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber);
256
+ return;
156
257
  }
157
- else {
158
- this.logger.warn({ prNumber, issueNumber }, 'Skipping thread replies due to CLI timeout or failure will retry on next cycle');
258
+ // FR-010: one warn per persistently-failed thread, emitted BEFORE
259
+ // clearing the label so a silent partial failure is impossible.
260
+ for (const f of resolveFailures) {
261
+ this.logger.warn({
262
+ prNumber, issueNumber, owner, repo,
263
+ threadId: f.threadId,
264
+ rootCommentId: f.rootCommentId,
265
+ error: f.resolveResult.error,
266
+ remedy: 'Resolve the thread manually in the GitHub UI — the reply is already on the thread',
267
+ }, 'resolveReviewThread persistently failed after retries; label will still be cleared');
159
268
  }
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');
269
+ // 10. Label-clear LAST (Q4 tail). #926 FR-006: coalesce the happy-path
270
+ // clear into a single `removeLabels` call so `waiting-for:*` and
271
+ // `agent:in-progress` disappear in one request — no intermediate state
272
+ // where cockpit / auto observers see one label without the other.
273
+ // The `finally` clear becomes a no-op on this path (idempotent remove).
274
+ try {
275
+ await github.removeLabels(owner, repo, issueNumber, [
276
+ WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL,
277
+ AGENT_IN_PROGRESS_LABEL,
278
+ ]);
279
+ this.logger.info({ issueNumber }, 'Removed waiting-for:address-pr-feedback + agent:in-progress labels (coalesced)');
165
280
  }
166
- else {
167
- this.logger.info({ prNumber, issueNumber, hasChanges }, 'Keeping waiting-for:address-pr-feedback label for retry (CLI did not complete successfully)');
281
+ catch (error) {
282
+ this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove happy-path labels non-fatal, finally will re-attempt in-progress clear');
168
283
  }
284
+ this.logger.info({
285
+ prNumber, issueNumber,
286
+ resolveSuccesses,
287
+ resolveFailures: resolveFailures.length,
288
+ shortSha,
289
+ }, 'PR feedback cycle succeeded (strict decrease met)');
169
290
  }
170
291
  catch (error) {
171
292
  this.logger.error({ error: String(error), prNumber, issueNumber, owner, repo }, 'Error processing PR feedback — task failed');
172
293
  throw error;
173
294
  }
295
+ finally {
296
+ // #926 SC-004: structural single-point clear. Every terminal exit
297
+ // (Case A, Case B, both blocked-stuck dispositions, happy path, and
298
+ // thrown errors) flows through here. Non-fatal on failure.
299
+ await this.clearInProgressLabel(github, owner, repo, issueNumber);
300
+ }
174
301
  }
175
302
  /**
176
303
  * Build a structured prompt containing all unresolved review comments.
@@ -190,11 +317,13 @@ export class PrFeedbackHandler {
190
317
  return `${idx + 1}. **${c.author}** (${location}):\n ${c.body}`;
191
318
  })
192
319
  .join('\n\n');
320
+ // #842: fence ingested thread content (author-trust filtered upstream).
321
+ const fenced = wrapUntrustedData(commentList, `PR #${prNumber} review comments`);
193
322
  return `You are addressing PR review feedback for PR #${prNumber} (linked to issue #${issueNumber}).
194
323
 
195
324
  The following unresolved review comments need to be addressed:
196
325
 
197
- ${commentList}
326
+ ${fenced}
198
327
 
199
328
  **Instructions:**
200
329
  1. Read and understand each review comment above
@@ -343,50 +472,92 @@ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`;
343
472
  return true;
344
473
  }
345
474
  /**
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.
475
+ * Post a single reply targeting the root comment of a review thread.
476
+ * Returns a discriminated result — failures do not throw so the caller
477
+ * (#883 per-thread outcome loop) can aggregate results.
351
478
  */
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
- }
479
+ async tryPostReply(github, owner, repo, prNumber, rootCommentId, body) {
480
+ try {
481
+ await github.replyToPRComment(owner, repo, prNumber, rootCommentId, body);
482
+ return { ok: true };
483
+ }
484
+ catch (error) {
485
+ return { ok: false, error: String(error) };
369
486
  }
370
- if (failureCount > 0) {
371
- this.logger.warn({ prNumber, successCount, failureCount, total: comments.length }, 'Some thread replies failed — partial success (acceptable per FR-007)');
487
+ }
488
+ /**
489
+ * Delegate to `github.resolveReviewThread` (which owns the 3× retry). Wraps
490
+ * the result in a discriminated union so the caller can aggregate outcomes
491
+ * without a try/catch (#883).
492
+ */
493
+ async tryResolveReviewThread(github, threadId) {
494
+ try {
495
+ await github.resolveReviewThread(threadId);
496
+ return { ok: true };
497
+ }
498
+ catch (error) {
499
+ return { ok: false, error: String(error) };
500
+ }
501
+ }
502
+ /**
503
+ * Read the short SHA of the just-pushed HEAD commit. Returns null when the
504
+ * git command fails; caller falls back to `<unknown>` in the reply body —
505
+ * the SHA is decoration, not termination logic (#883).
506
+ */
507
+ async getHeadShortSha(checkoutPath) {
508
+ try {
509
+ const result = await executeCommand('git', ['rev-parse', '--short', 'HEAD'], { cwd: checkoutPath });
510
+ if (result.exitCode !== 0)
511
+ return null;
512
+ const sha = result.stdout.trim();
513
+ return sha.length > 0 ? sha : null;
372
514
  }
373
- else {
374
- this.logger.info({ prNumber, successCount, total: comments.length }, 'Successfully posted all thread replies');
515
+ catch {
516
+ return null;
517
+ }
518
+ }
519
+ /**
520
+ * Add the `blocked:stuck-feedback-loop` label to signal that the fix cycle
521
+ * cannot advance and must not be re-enqueued until the operator removes the
522
+ * label. Non-fatal on failure — leaving `waiting-for:*` in place is the
523
+ * fallback safety net (#883).
524
+ */
525
+ async addBlockedStuckFeedbackLoopLabel(github, owner, repo, issueNumber) {
526
+ try {
527
+ await github.addLabels(owner, repo, issueNumber, [BLOCKED_STUCK_FEEDBACK_LOOP_LABEL]);
528
+ this.logger.info({ issueNumber, label: BLOCKED_STUCK_FEEDBACK_LOOP_LABEL }, 'Added blocked:stuck-feedback-loop label');
529
+ }
530
+ catch (error) {
531
+ 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
532
  }
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
533
  }
379
534
  /**
380
535
  * Remove the `waiting-for:address-pr-feedback` label from the linked issue.
381
536
  */
382
537
  async removeFeedbackLabel(github, owner, repo, issueNumber) {
383
538
  try {
384
- await github.removeLabels(owner, repo, issueNumber, ['waiting-for:address-pr-feedback']);
539
+ await github.removeLabels(owner, repo, issueNumber, [WAITING_FOR_ADDRESS_PR_FEEDBACK_LABEL]);
385
540
  this.logger.info({ issueNumber }, 'Removed waiting-for:address-pr-feedback label');
386
541
  }
387
542
  catch (error) {
388
543
  this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove waiting-for:address-pr-feedback label — non-fatal');
389
544
  }
390
545
  }
546
+ /**
547
+ * Structural clear of `agent:in-progress` on the linked issue (#926 SC-004).
548
+ * Called from the shared `finally` block in `handle()` — runs on every
549
+ * terminal exit path (Cases A/B, both blocked-stuck dispositions, happy
550
+ * path, and thrown errors). Idempotent: GitHub's `removeLabels` is a no-op
551
+ * when the label is already absent, so the happy-path coalesced removal +
552
+ * this `finally` clear together produce at most one truthful post-state.
553
+ */
554
+ async clearInProgressLabel(github, owner, repo, issueNumber) {
555
+ try {
556
+ await github.removeLabels(owner, repo, issueNumber, [AGENT_IN_PROGRESS_LABEL]);
557
+ }
558
+ catch (error) {
559
+ this.logger.warn({ error: String(error), issueNumber }, 'Failed to remove agent:in-progress label — non-fatal');
560
+ }
561
+ }
391
562
  }
392
563
  //# sourceMappingURL=pr-feedback-handler.js.map