night-orch 0.3.2 → 0.4.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 (221) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -108
  3. package/dist/cli/commands/doctor.d.ts +1 -0
  4. package/dist/cli/commands/doctor.d.ts.map +1 -1
  5. package/dist/cli/commands/doctor.js +18 -0
  6. package/dist/cli/commands/doctor.js.map +1 -1
  7. package/dist/cli/commands/monitoring.d.ts.map +1 -1
  8. package/dist/cli/commands/monitoring.js +7 -3
  9. package/dist/cli/commands/monitoring.js.map +1 -1
  10. package/dist/cli/commands/settings.d.ts.map +1 -1
  11. package/dist/cli/commands/settings.js +40 -4
  12. package/dist/cli/commands/settings.js.map +1 -1
  13. package/dist/cli/commands/status.d.ts.map +1 -1
  14. package/dist/cli/commands/status.js +26 -3
  15. package/dist/cli/commands/status.js.map +1 -1
  16. package/dist/cli/index.js +3 -2
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/cli/tui/app.d.ts.map +1 -1
  19. package/dist/cli/tui/app.js +52 -3
  20. package/dist/cli/tui/app.js.map +1 -1
  21. package/dist/cli/tui/header.d.ts.map +1 -1
  22. package/dist/cli/tui/header.js +2 -1
  23. package/dist/cli/tui/header.js.map +1 -1
  24. package/dist/cli/tui/settings-view.d.ts.map +1 -1
  25. package/dist/cli/tui/settings-view.js +22 -5
  26. package/dist/cli/tui/settings-view.js.map +1 -1
  27. package/dist/cli/tui/stats-view.js +2 -2
  28. package/dist/cli/tui/stats-view.js.map +1 -1
  29. package/dist/config/loader.d.ts.map +1 -1
  30. package/dist/config/loader.js +141 -13
  31. package/dist/config/loader.js.map +1 -1
  32. package/dist/config/schema.d.ts +903 -21
  33. package/dist/config/schema.d.ts.map +1 -1
  34. package/dist/config/schema.js +77 -6
  35. package/dist/config/schema.js.map +1 -1
  36. package/dist/forge/types.d.ts +7 -0
  37. package/dist/forge/types.d.ts.map +1 -1
  38. package/dist/git/repo.d.ts +8 -0
  39. package/dist/git/repo.d.ts.map +1 -1
  40. package/dist/git/repo.js +19 -0
  41. package/dist/git/repo.js.map +1 -1
  42. package/dist/git/worktree.d.ts +3 -0
  43. package/dist/git/worktree.d.ts.map +1 -1
  44. package/dist/git/worktree.js +43 -21
  45. package/dist/git/worktree.js.map +1 -1
  46. package/dist/loop/cost.d.ts +33 -8
  47. package/dist/loop/cost.d.ts.map +1 -1
  48. package/dist/loop/cost.js +250 -46
  49. package/dist/loop/cost.js.map +1 -1
  50. package/dist/loop/decision.js +3 -3
  51. package/dist/loop/decision.js.map +1 -1
  52. package/dist/loop/engine.d.ts +1 -0
  53. package/dist/loop/engine.d.ts.map +1 -1
  54. package/dist/loop/engine.js +76 -13
  55. package/dist/loop/engine.js.map +1 -1
  56. package/dist/loop/parallel.d.ts.map +1 -1
  57. package/dist/loop/parallel.js +2 -0
  58. package/dist/loop/parallel.js.map +1 -1
  59. package/dist/loop/pricing.d.ts +11 -4
  60. package/dist/loop/pricing.d.ts.map +1 -1
  61. package/dist/loop/pricing.js +62 -20
  62. package/dist/loop/pricing.js.map +1 -1
  63. package/dist/loop/progress.d.ts +24 -0
  64. package/dist/loop/progress.d.ts.map +1 -0
  65. package/dist/loop/progress.js +52 -0
  66. package/dist/loop/progress.js.map +1 -0
  67. package/dist/loop/step-executor.d.ts +4 -5
  68. package/dist/loop/step-executor.d.ts.map +1 -1
  69. package/dist/loop/step-executor.js +1 -0
  70. package/dist/loop/step-executor.js.map +1 -1
  71. package/dist/loop/types.d.ts +17 -0
  72. package/dist/loop/types.d.ts.map +1 -1
  73. package/dist/mcp/tools/admin.d.ts +29 -0
  74. package/dist/mcp/tools/admin.d.ts.map +1 -0
  75. package/dist/mcp/tools/admin.js +89 -0
  76. package/dist/mcp/tools/admin.js.map +1 -0
  77. package/dist/mcp/tools/auth.d.ts +3 -0
  78. package/dist/mcp/tools/auth.d.ts.map +1 -0
  79. package/dist/mcp/tools/auth.js +19 -0
  80. package/dist/mcp/tools/auth.js.map +1 -0
  81. package/dist/mcp/tools/index.d.ts.map +1 -1
  82. package/dist/mcp/tools/index.js +5 -533
  83. package/dist/mcp/tools/index.js.map +1 -1
  84. package/dist/mcp/tools/operations.d.ts +32 -0
  85. package/dist/mcp/tools/operations.d.ts.map +1 -0
  86. package/dist/mcp/tools/operations.js +95 -0
  87. package/dist/mcp/tools/operations.js.map +1 -0
  88. package/dist/mcp/tools/settings.d.ts +12 -0
  89. package/dist/mcp/tools/settings.d.ts.map +1 -0
  90. package/dist/mcp/tools/settings.js +58 -0
  91. package/dist/mcp/tools/settings.js.map +1 -0
  92. package/dist/mcp/tools/status.d.ts +25 -0
  93. package/dist/mcp/tools/status.d.ts.map +1 -0
  94. package/dist/mcp/tools/status.js +307 -0
  95. package/dist/mcp/tools/status.js.map +1 -0
  96. package/dist/ops/continue.js +6 -1
  97. package/dist/ops/continue.js.map +1 -1
  98. package/dist/ops/project-check.d.ts +15 -0
  99. package/dist/ops/project-check.d.ts.map +1 -0
  100. package/dist/ops/project-check.js +136 -0
  101. package/dist/ops/project-check.js.map +1 -0
  102. package/dist/ops/rebase-and-check.d.ts +2 -1
  103. package/dist/ops/rebase-and-check.d.ts.map +1 -1
  104. package/dist/ops/rebase-and-check.js +2 -2
  105. package/dist/ops/rebase-and-check.js.map +1 -1
  106. package/dist/ops/rebase.d.ts +8 -5
  107. package/dist/ops/rebase.d.ts.map +1 -1
  108. package/dist/ops/rebase.js +43 -29
  109. package/dist/ops/rebase.js.map +1 -1
  110. package/dist/runner/comment-commands.d.ts +20 -0
  111. package/dist/runner/comment-commands.d.ts.map +1 -0
  112. package/dist/runner/comment-commands.js +221 -0
  113. package/dist/runner/comment-commands.js.map +1 -0
  114. package/dist/runner/helpers.d.ts +57 -0
  115. package/dist/runner/helpers.d.ts.map +1 -0
  116. package/dist/runner/helpers.js +259 -0
  117. package/dist/runner/helpers.js.map +1 -0
  118. package/dist/runner/poller.d.ts.map +1 -1
  119. package/dist/runner/poller.js +19 -781
  120. package/dist/runner/poller.js.map +1 -1
  121. package/dist/runner/reaction-scan.d.ts +17 -0
  122. package/dist/runner/reaction-scan.d.ts.map +1 -0
  123. package/dist/runner/reaction-scan.js +33 -0
  124. package/dist/runner/reaction-scan.js.map +1 -0
  125. package/dist/runner/run-finalizer.d.ts +30 -0
  126. package/dist/runner/run-finalizer.d.ts.map +1 -0
  127. package/dist/runner/run-finalizer.js +217 -0
  128. package/dist/runner/run-finalizer.js.map +1 -0
  129. package/dist/settings/definitions/github.d.ts +3 -0
  130. package/dist/settings/definitions/github.d.ts.map +1 -0
  131. package/dist/settings/definitions/github.js +267 -0
  132. package/dist/settings/definitions/github.js.map +1 -0
  133. package/dist/settings/definitions/loop.d.ts +3 -0
  134. package/dist/settings/definitions/loop.d.ts.map +1 -0
  135. package/dist/settings/definitions/loop.js +113 -0
  136. package/dist/settings/definitions/loop.js.map +1 -0
  137. package/dist/settings/definitions/observability.d.ts +3 -0
  138. package/dist/settings/definitions/observability.d.ts.map +1 -0
  139. package/dist/settings/definitions/observability.js +74 -0
  140. package/dist/settings/definitions/observability.js.map +1 -0
  141. package/dist/settings/definitions/security.d.ts +3 -0
  142. package/dist/settings/definitions/security.d.ts.map +1 -0
  143. package/dist/settings/definitions/security.js +121 -0
  144. package/dist/settings/definitions/security.js.map +1 -0
  145. package/dist/settings/registry.d.ts +82 -6
  146. package/dist/settings/registry.d.ts.map +1 -1
  147. package/dist/settings/registry.js +301 -194
  148. package/dist/settings/registry.js.map +1 -1
  149. package/dist/settings/runtime.d.ts +5 -1
  150. package/dist/settings/runtime.d.ts.map +1 -1
  151. package/dist/settings/runtime.js +46 -9
  152. package/dist/settings/runtime.js.map +1 -1
  153. package/dist/state/db.d.ts.map +1 -1
  154. package/dist/state/db.js +2 -0
  155. package/dist/state/db.js.map +1 -1
  156. package/dist/state/migrations/020-cost-ledger.d.ts +3 -0
  157. package/dist/state/migrations/020-cost-ledger.d.ts.map +1 -0
  158. package/dist/state/migrations/020-cost-ledger.js +37 -0
  159. package/dist/state/migrations/020-cost-ledger.js.map +1 -0
  160. package/dist/state/runs.d.ts +1 -0
  161. package/dist/state/runs.d.ts.map +1 -1
  162. package/dist/state/runs.js +3 -0
  163. package/dist/state/runs.js.map +1 -1
  164. package/dist/state/stats.d.ts +20 -0
  165. package/dist/state/stats.d.ts.map +1 -1
  166. package/dist/state/stats.js +68 -8
  167. package/dist/state/stats.js.map +1 -1
  168. package/dist/utils/logger.d.ts +9 -0
  169. package/dist/utils/logger.d.ts.map +1 -1
  170. package/dist/utils/logger.js +13 -0
  171. package/dist/utils/logger.js.map +1 -1
  172. package/dist/web/routes/api-events.d.ts +10 -0
  173. package/dist/web/routes/api-events.d.ts.map +1 -0
  174. package/dist/web/routes/api-events.js +251 -0
  175. package/dist/web/routes/api-events.js.map +1 -0
  176. package/dist/web/routes/api-operations.d.ts +3 -0
  177. package/dist/web/routes/api-operations.d.ts.map +1 -0
  178. package/dist/web/routes/api-operations.js +371 -0
  179. package/dist/web/routes/api-operations.js.map +1 -0
  180. package/dist/web/routes/api-runs.d.ts +3 -0
  181. package/dist/web/routes/api-runs.d.ts.map +1 -0
  182. package/dist/web/routes/api-runs.js +96 -0
  183. package/dist/web/routes/api-runs.js.map +1 -0
  184. package/dist/web/routes/api-settings.d.ts +3 -0
  185. package/dist/web/routes/api-settings.d.ts.map +1 -0
  186. package/dist/web/routes/api-settings.js +61 -0
  187. package/dist/web/routes/api-settings.js.map +1 -0
  188. package/dist/web/routes/context.d.ts +15 -0
  189. package/dist/web/routes/context.d.ts.map +1 -0
  190. package/dist/web/routes/context.js +2 -0
  191. package/dist/web/routes/context.js.map +1 -0
  192. package/dist/web/server.d.ts +58 -1
  193. package/dist/web/server.d.ts.map +1 -1
  194. package/dist/web/server.js +116 -847
  195. package/dist/web/server.js.map +1 -1
  196. package/dist/web/shell-session.d.ts +74 -0
  197. package/dist/web/shell-session.d.ts.map +1 -0
  198. package/dist/web/shell-session.js +279 -0
  199. package/dist/web/shell-session.js.map +1 -0
  200. package/dist/web/snapshots.d.ts +159 -0
  201. package/dist/web/snapshots.d.ts.map +1 -0
  202. package/dist/web/snapshots.js +231 -0
  203. package/dist/web/snapshots.js.map +1 -0
  204. package/dist/workers/acp.d.ts.map +1 -1
  205. package/dist/workers/acp.js +116 -0
  206. package/dist/workers/acp.js.map +1 -1
  207. package/dist/workers/claude.d.ts.map +1 -1
  208. package/dist/workers/claude.js +13 -3
  209. package/dist/workers/claude.js.map +1 -1
  210. package/dist/workers/codex.d.ts.map +1 -1
  211. package/dist/workers/codex.js +16 -4
  212. package/dist/workers/codex.js.map +1 -1
  213. package/dist/workers/types.d.ts +14 -4
  214. package/dist/workers/types.d.ts.map +1 -1
  215. package/examples/config.example.yaml +12 -3
  216. package/package.json +8 -2
  217. package/web/dist/assets/index-BIrXUwFe.css +1 -0
  218. package/web/dist/assets/index-COMzHPcP.js +26 -0
  219. package/web/dist/index.html +2 -2
  220. package/web/dist/assets/index-k6kgdnzy.js +0 -9
  221. package/web/dist/assets/index-xm9qPlYB.css +0 -1
@@ -11,8 +11,6 @@ import { resolveEnvironmentMode, setupEnvironment, teardownEnvironment, } from '
11
11
  import { createWorkerAdapter } from '../workers/factory.js';
12
12
  import { executeLoop } from '../loop/engine.js';
13
13
  import { resolveWorkflow } from '../loop/workflow.js';
14
- import { publishPR } from '../publishing/publisher.js';
15
- import { MergeConflictError } from '../publishing/push.js';
16
14
  import { transitionLabels } from '../labels/manager.js';
17
15
  import { buildLabelConfig } from '../labels/config.js';
18
16
  import { NotificationDispatcher } from '../notify/dispatcher.js';
@@ -22,21 +20,31 @@ import { branchName } from '../utils/ids.js';
22
20
  import { logger } from '../utils/logger.js';
23
21
  import { nowUtcIso } from '../utils/time.js';
24
22
  import { postPlanSummaryComment } from '../loop/plan-summary-comment.js';
25
- import { executeRebase, queueRebase } from '../ops/rebase-and-check.js';
26
- import { queueContinue } from '../ops/continue.js';
23
+ import { executeRebase } from '../ops/rebase-and-check.js';
27
24
  import { markerTag, upsertBotComment } from '../forge/bot-comment.js';
28
25
  import { formatStatusComment } from '../forge/status-comment.js';
29
- import { scanForReactions } from '../reactions/scanner.js';
30
- import { handleReaction } from '../reactions/handler.js';
31
26
  import { processMergeQueue } from '../merge-queue/runner.js';
32
27
  import { decomposeIssue, shouldAttemptDecompose } from '../discovery/decomposer.js';
33
28
  import { executeParallelSubtasks } from '../loop/parallel.js';
34
29
  import { buildWorkerEnv } from '../workers/env.js';
35
30
  import { isPlanningIssue } from '../planning/mode.js';
36
- import { resolveIssueRepo } from '../utils/issue-repo.js';
37
- import { isCommandProcessed, markCommandProcessed, parseOrchCommands, } from '../discovery/commands.js';
38
31
  import { AgentObservability, setActiveAgentObservability, clearActiveAgentObservability, } from '../events/observability.js';
32
+ // Extracted modules
33
+ import { finalizeRunOutcome } from './run-finalizer.js';
34
+ import { processCommentCommands, missingCommentCommandIssues } from './comment-commands.js';
35
+ import { scanAndHandleReactions, reactionCursors } from './reaction-scan.js';
36
+ import { coerceAgentName, isImmediateFollowupStatus, applyWorkflowAgentOverrides, applyWorkflowRoleDefaults, resolveWorkerProfileForAgent, extractFollowupPromptFeedback, prioritizeDiscoveredIssues, selectReplayableRun, shouldResetBranch, makePayload, postStatusComment, postErrorStatusComment, toErrorMessage, sanitizeErrorForComment, } from './helpers.js';
39
37
  const STATUS_MARKER = markerTag('status');
38
+ /**
39
+ * Evict entries from process-global caches that are keyed by repo+issue.
40
+ * Called when a run reaches a terminal state so the caches don't grow
41
+ * unbounded over the daemon's lifetime.
42
+ */
43
+ function cleanupRunCaches(repo, issueNumber) {
44
+ const key = `${repo}#${issueNumber}`;
45
+ missingCommentCommandIssues.delete(key);
46
+ reactionCursors.delete(key);
47
+ }
40
48
  /**
41
49
  * Process one poll cycle: discover eligible issues, claim and process.
42
50
  * Repositories are processed in parallel; each repo runs up to
@@ -175,11 +183,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
175
183
  }
176
184
  continue;
177
185
  }
178
- // Lease duration is deliberately short (30 min) so a
179
- // crashed poller's leases expire promptly. The engine
180
- // bumps the deadline on every phase checkpoint via
181
- // leaseHeartbeat below, so a long run is not at risk of
182
- // expiring mid-work.
183
186
  if (!leaseManager.acquire(issueRepo, discoveredIssue.issue.number, 'poller', 1800)) {
184
187
  continue;
185
188
  }
@@ -244,12 +247,10 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
244
247
  // Detect if this queued run needs a forced branch reset (e.g., after merge conflict)
245
248
  const forceReset = activeRun?.blockReason === 'merge_conflict';
246
249
  // Detect rebase mode from queued run's phaseData
247
- // If force-resetting, ignore stale rebase context — we're starting fresh
248
250
  const reactionType = activeRun?.phaseData?.reactionType;
249
251
  const isRebaseRun = !forceReset && (reactionType === 'rebase' || reactionType === 'merge_conflict');
250
252
  const followupPromptFeedback = extractFollowupPromptFeedback(activeRun?.phaseData);
251
253
  // Check if prior run left tainted work that should be discarded
252
- // Never reset to base for rebase runs — we need the existing branch
253
254
  const planningMode = isPlanningIssue(discoveredIssue.issue.labels, repoConfigForRun);
254
255
  const resetToBase = forceReset || (!isRebaseRun && (planningMode || shouldResetBranch(runManager, repoConfig.repo, discoveredIssue.issue.number, run.id)));
255
256
  // Create worktree
@@ -259,14 +260,14 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
259
260
  branchName: branch,
260
261
  worktreePath,
261
262
  resetToBase,
263
+ updateStrategy: repoConfig.updateStrategy,
262
264
  });
263
265
  // Execute rebase if this is a rebase-queued run
264
266
  if (isRebaseRun) {
265
267
  logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, runId: run.id }, 'Executing rebase for queued rebase run');
266
268
  const verifyCommands = repoConfig.verify ?? [];
267
- const rebaseResult = await executeRebase(repoConfig.localPath, worktreePath, branch, repoConfig.baseBranch, issueRepo, discoveredIssue.issue.number, verifyCommands);
269
+ const rebaseResult = await executeRebase(repoConfig.localPath, worktreePath, branch, repoConfig.baseBranch, issueRepo, discoveredIssue.issue.number, verifyCommands, repoConfig.updateStrategy);
268
270
  if (rebaseResult.conflict) {
269
- // Rebase had conflicts — block the run; retry will reset the branch and re-implement
270
271
  runManager.update(run.id, {
271
272
  status: 'blocked',
272
273
  blockReason: 'merge_conflict',
@@ -295,7 +296,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
295
296
  continue;
296
297
  }
297
298
  if (rebaseResult.rebased && rebaseResult.verifyPassed) {
298
- // Rebase succeeded and verify passes — done, transition back to review_ready
299
299
  logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Rebase succeeded, verify passed — returning to review_ready');
300
300
  runManager.update(run.id, {
301
301
  status: 'review_ready',
@@ -312,7 +312,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
312
312
  continue;
313
313
  }
314
314
  if (!rebaseResult.rebased && rebaseResult.verifyPassed) {
315
- // Already up-to-date and verify passes — nothing to do
316
315
  logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Branch already up to date — returning to review_ready');
317
316
  runManager.update(run.id, {
318
317
  status: 'review_ready',
@@ -325,8 +324,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
325
324
  repoProcessed++;
326
325
  continue;
327
326
  }
328
- // Rebase succeeded but verify failed — fall through to the loop engine
329
- // so the coder can fix the issues introduced by upstream changes
330
327
  logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number }, 'Rebase done but verify failed — entering code loop to fix');
331
328
  }
332
329
  if (repoConfigForRun.environment) {
@@ -377,6 +374,7 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
377
374
  prReviewFeedback: followupPromptFeedback,
378
375
  sessionIds: {},
379
376
  stepOutputs: {},
377
+ iterationSnapshots: [],
380
378
  };
381
379
  // Check if decomposition is enabled and appropriate
382
380
  const shouldDecompose = config.loop.decompose
@@ -466,17 +464,12 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
466
464
  repoProcessed++;
467
465
  else
468
466
  repoErrors++;
469
- // Release per-run observability resources (session log
470
- // streams + event bus history) once the run has reached
471
- // a terminal state — without this both Maps grow for the
472
- // daemon's entire lifetime.
473
467
  try {
474
468
  await observability.closeRun(run.id);
475
469
  }
476
470
  catch (closeErr) {
477
471
  logger.debug({ runId: run.id, err: closeErr }, 'closeRun failed (best-effort)');
478
472
  }
479
- // Evict per-issue caches so they don't grow unbounded.
480
473
  cleanupRunCaches(repoConfig.repo, discoveredIssue.issue.number);
481
474
  }
482
475
  catch (err) {
@@ -494,11 +487,7 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
494
487
  endedAt: nowUtcIso(),
495
488
  });
496
489
  if (canAutoRetry) {
497
- // Bump retry_count on the same row so repeated replay
498
- // retries converge on maxRetries. Without this the
499
- // same run row cycles error → queued → error forever.
500
490
  runManager.incrementRetryCount(runId);
501
- // Auto-retry: transition back to queued so the next poll picks it up
502
491
  logger.info({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, attempt: attemptCount, maxRetries }, 'Infra error — auto-retrying (transitioning back to ready)');
503
492
  try {
504
493
  const latestIssue = await forge.getIssue(issueRepo, discoveredIssue.issue.number);
@@ -520,7 +509,6 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
520
509
  });
521
510
  }
522
511
  else {
523
- // Retries exhausted: mark as error, require human
524
512
  logger.warn({ repo: repoConfig.repo, issue: discoveredIssue.issue.number, currentRetries, maxRetries }, 'Auto-retry limit reached — marking as error');
525
513
  try {
526
514
  const latestIssue = await forge.getIssue(issueRepo, discoveredIssue.issue.number);
@@ -607,754 +595,4 @@ export async function pollOnce(config, db, dryRun, metrics, targetIssue) {
607
595
  await observability.close();
608
596
  }
609
597
  }
610
- async function finalizeRunOutcome(params) {
611
- const { finalCtx, runId, issue, runDurationSec, repo, repoConfig, issueRepo, issueNumber, db, forge, runManager, notifier, metrics, maxAutoRetries, botUser, } = params;
612
- if (finalCtx.terminalStatus === 'publish') {
613
- try {
614
- const publishResult = await publishPR(finalCtx, forge, db);
615
- runManager.update(runId, {
616
- status: 'review_ready',
617
- iterationCount: finalCtx.iteration,
618
- prNumber: publishResult.prNumber,
619
- prTitle: publishResult.prTitle,
620
- endedAt: nowUtcIso(),
621
- });
622
- const latestIssue = await forge.getIssue(issueRepo, issueNumber);
623
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'review_ready', buildLabelConfig(repoConfig, latestIssue.labels));
624
- const notificationEvent = publishResult.created ? 'pr_ready' : 'pr_updated';
625
- const notifyResult = await notifier.dispatch(makePayload(notificationEvent, repo, issue, {
626
- prUrl: publishResult.prUrl,
627
- prNumber: publishResult.prNumber,
628
- summary: publishResult.created
629
- ? `PR ready: ${publishResult.prUrl}`
630
- : `PR updated: ${publishResult.prUrl}`,
631
- }));
632
- try {
633
- metrics?.incRunsTotal('completed');
634
- metrics?.observeRunDuration(runDurationSec);
635
- metrics?.incPROperations(publishResult.created ? 'created' : 'updated');
636
- for (const s of notifyResult.sent) {
637
- metrics?.incNotifications(s.channel, s.success ? 'sent' : 'failed');
638
- }
639
- }
640
- catch { /* best-effort */ }
641
- return 'processed';
642
- }
643
- catch (err) {
644
- logger.error({ err }, 'Failed to publish PR');
645
- const errorMessage = toErrorMessage(err);
646
- // Merge conflicts during push get structured block reason so retry resets the branch
647
- if (err instanceof MergeConflictError) {
648
- runManager.update(runId, {
649
- status: 'blocked',
650
- iterationCount: finalCtx.iteration,
651
- blockReason: 'merge_conflict',
652
- lastError: err.message,
653
- endedAt: nowUtcIso(),
654
- });
655
- const latestIssue = await forge.getIssue(issueRepo, issueNumber);
656
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'blocked', buildLabelConfig(repoConfig, latestIssue.labels), 'merge_conflict');
657
- await postStatusComment({
658
- forge,
659
- issueRepo,
660
- issueNumber,
661
- botUser,
662
- body: formatStatusComment({
663
- blockReason: 'Publish failed due to merge conflicts while pushing branch updates.',
664
- nextStep: 'Run /orch retry to reset the branch to base and re-implement on top of latest main.',
665
- }),
666
- warnMessage: 'Failed to post publish merge-conflict status comment',
667
- });
668
- try {
669
- metrics?.incRunsTotal('blocked');
670
- metrics?.observeRunDuration(runDurationSec);
671
- }
672
- catch { /* best-effort */ }
673
- return 'error';
674
- }
675
- const currentRun = runManager.getById(runId);
676
- const currentRetries = currentRun?.retryCount ?? 0;
677
- runManager.update(runId, { status: 'error', iterationCount: finalCtx.iteration, lastError: errorMessage, endedAt: nowUtcIso() });
678
- const latestIssue = await forge.getIssue(issueRepo, issueNumber);
679
- if (currentRetries < maxAutoRetries) {
680
- runManager.incrementRetryCount(runId);
681
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'queued', buildLabelConfig(repoConfig, latestIssue.labels));
682
- logger.info({ repo, issueNumber, attempt: currentRetries + 1, maxAutoRetries }, 'Publish failed — auto-retrying');
683
- await postErrorStatusComment({
684
- forge,
685
- issueRepo,
686
- issueNumber,
687
- botUser,
688
- error: `Publish failed. Last error: ${errorMessage}`,
689
- retryCount: currentRetries + 1,
690
- maxRetries: maxAutoRetries,
691
- nextStep: 'Automatic retry queued. night-orch will retry this issue on the next poll cycle.',
692
- warnMessage: 'Failed to post publish auto-retry status comment',
693
- });
694
- }
695
- else {
696
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'error', buildLabelConfig(repoConfig, latestIssue.labels));
697
- const attemptCount = currentRetries + 1;
698
- await postErrorStatusComment({
699
- forge,
700
- issueRepo,
701
- issueNumber,
702
- botUser,
703
- error: `Failed after ${attemptCount} attempts. Last error: ${errorMessage}`,
704
- retryCount: attemptCount,
705
- maxRetries: maxAutoRetries,
706
- nextStep: 'Manual action required: inspect the failure, then run /orch retry or /orch continue.',
707
- warnMessage: 'Failed to post publish retry-exhausted status comment',
708
- });
709
- const sanitizedErrorForSummary = sanitizeErrorForComment(errorMessage);
710
- try {
711
- await notifier.dispatch(makePayload('retry_exhausted', repo, issue, {
712
- summary: `Publish failed after ${attemptCount} attempts: ${sanitizedErrorForSummary}`,
713
- }));
714
- }
715
- catch (notifyErr) {
716
- logger.warn({ repo, issueNumber, err: notifyErr }, 'Failed to send publish retry exhaustion notification');
717
- }
718
- }
719
- try {
720
- metrics?.incRunsTotal('error');
721
- metrics?.observeRunDuration(runDurationSec);
722
- }
723
- catch { /* best-effort */ }
724
- return 'error';
725
- }
726
- }
727
- if (finalCtx.terminalStatus === 'blocked') {
728
- const blockReason = buildBlockReason(finalCtx);
729
- runManager.update(runId, {
730
- status: 'blocked',
731
- iterationCount: finalCtx.iteration,
732
- lastError: blockReason,
733
- blockReason: finalCtx.blockReason ?? null,
734
- endedAt: nowUtcIso(),
735
- });
736
- const latestIssue = await forge.getIssue(issueRepo, issueNumber);
737
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'blocked', buildLabelConfig(repoConfig, latestIssue.labels), finalCtx.blockReason ?? undefined);
738
- // Upsert status comment with block reason
739
- try {
740
- const statusBody = formatStatusComment({
741
- blockReason,
742
- iteration: finalCtx.iteration,
743
- maxIterations: finalCtx.adjustedLimits.maxReviewIterations,
744
- cost: finalCtx.estimatedCostUsd,
745
- });
746
- if (botUser) {
747
- await upsertBotComment(forge, issueRepo, issueNumber, STATUS_MARKER, statusBody, botUser);
748
- }
749
- else {
750
- await forge.commentOnIssue(issueRepo, issueNumber, formatBlockComment(blockReason, finalCtx));
751
- }
752
- }
753
- catch (commentErr) {
754
- logger.warn({ repo, issueNumber, err: commentErr }, 'Failed to post block reason comment');
755
- }
756
- const notifyResult = await notifier.dispatch(makePayload('blocked', repo, issue, {
757
- summary: blockReason,
758
- blockingReason: blockReason,
759
- reviewSummary: finalCtx.reviewResult?.summary ?? null,
760
- }));
761
- try {
762
- metrics?.incRunsTotal('blocked');
763
- metrics?.observeRunDuration(runDurationSec);
764
- for (const s of notifyResult.sent) {
765
- metrics?.incNotifications(s.channel, s.success ? 'sent' : 'failed');
766
- }
767
- }
768
- catch { /* best-effort */ }
769
- return 'processed';
770
- }
771
- const unexpectedError = `Loop ended in unexpected state: ${finalCtx.terminalStatus}/${finalCtx.currentPhase}`;
772
- const currentRunForUnexpected = runManager.getById(runId);
773
- const currentRetriesUnexpected = currentRunForUnexpected?.retryCount ?? 0;
774
- runManager.update(runId, { status: 'error', iterationCount: finalCtx.iteration, lastError: unexpectedError, endedAt: nowUtcIso() });
775
- const latestIssue = await forge.getIssue(issueRepo, issueNumber);
776
- if (currentRetriesUnexpected < maxAutoRetries) {
777
- runManager.incrementRetryCount(runId);
778
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'queued', buildLabelConfig(repoConfig, latestIssue.labels));
779
- logger.info({ repo, issueNumber, attempt: currentRetriesUnexpected + 1, maxAutoRetries }, 'Unexpected state — auto-retrying');
780
- await postErrorStatusComment({
781
- forge,
782
- issueRepo,
783
- issueNumber,
784
- botUser,
785
- error: `Loop entered unexpected state: ${finalCtx.terminalStatus}/${finalCtx.currentPhase}`,
786
- retryCount: currentRetriesUnexpected + 1,
787
- maxRetries: maxAutoRetries,
788
- nextStep: 'Automatic retry queued. night-orch will retry this issue on the next poll cycle.',
789
- warnMessage: 'Failed to post unexpected-state auto-retry status comment',
790
- });
791
- }
792
- else {
793
- await transitionLabels(forge, issueRepo, issueNumber, latestIssue.labels, 'running', 'error', buildLabelConfig(repoConfig, latestIssue.labels));
794
- const attemptCount = currentRetriesUnexpected + 1;
795
- await postErrorStatusComment({
796
- forge,
797
- issueRepo,
798
- issueNumber,
799
- botUser,
800
- error: `Failed after ${attemptCount} attempts. Last error: ${unexpectedError}`,
801
- retryCount: attemptCount,
802
- maxRetries: maxAutoRetries,
803
- nextStep: 'Manual action required: inspect the failure, then run /orch retry or /orch continue.',
804
- warnMessage: 'Failed to post unexpected-state retry-exhausted status comment',
805
- });
806
- }
807
- try {
808
- metrics?.incRunsTotal('error');
809
- metrics?.observeRunDuration(runDurationSec);
810
- }
811
- catch { /* best-effort */ }
812
- return 'error';
813
- }
814
- function coerceAgentName(value, fallback) {
815
- if (value === 'claude' || value === 'codex' || value === 'opencode') {
816
- return value;
817
- }
818
- return fallback;
819
- }
820
- function isImmediateFollowupStatus(status) {
821
- return status === 'review_ready'
822
- || status === 'blocked'
823
- || status === 'error'
824
- || status === 'completed';
825
- }
826
- function applyWorkflowAgentOverrides(repoConfig, workflow) {
827
- if (!workflow.agents || Object.keys(workflow.agents).length === 0) {
828
- return repoConfig;
829
- }
830
- return {
831
- ...repoConfig,
832
- agents: {
833
- ...repoConfig.agents,
834
- ...workflow.agents,
835
- },
836
- };
837
- }
838
- function applyWorkflowRoleDefaults(repoDefaults, workflow, repoConfig, config) {
839
- if (!workflow.roles) {
840
- return repoDefaults;
841
- }
842
- const merged = {
843
- ...repoDefaults,
844
- ...workflow.roles,
845
- };
846
- for (const role of ['planner', 'coder', 'reviewer']) {
847
- const preferredAgent = merged[role];
848
- if (canResolveAgent(preferredAgent, repoConfig, config))
849
- continue;
850
- merged[role] = repoDefaults[role];
851
- }
852
- return merged;
853
- }
854
- function canResolveAgent(agent, repoConfig, config) {
855
- return resolveWorkerProfileForAgent(agent, repoConfig, config) !== null;
856
- }
857
- function resolveWorkerProfileForAgent(agent, repoConfig, config) {
858
- const mappedProfileName = repoConfig.agents[agent];
859
- if (mappedProfileName) {
860
- const mappedProfile = config.workerProfiles[mappedProfileName];
861
- if (mappedProfile)
862
- return mappedProfile;
863
- }
864
- return Object.values(config.workerProfiles).find((profile) => profile.type === agent) ?? null;
865
- }
866
- function buildBlockReason(ctx) {
867
- const blockMessage = ctx.stepOutputs?.['blockMessage'];
868
- if (typeof blockMessage === 'string' && blockMessage.trim().length > 0) {
869
- return blockMessage;
870
- }
871
- if (ctx.reviewResult) {
872
- const findings = ctx.reviewResult.findings
873
- .filter((f) => f.severity === 'critical' || f.severity === 'major')
874
- .map((f) => `[${f.severity}] ${f.message}`)
875
- .join('; ');
876
- return findings
877
- ? `${ctx.reviewResult.summary} — ${findings}`
878
- : ctx.reviewResult.summary;
879
- }
880
- if (ctx.blockReason) {
881
- return blockReasonSummary(ctx.blockReason, ctx);
882
- }
883
- return `Blocked in phase ${ctx.currentPhase} (no review result available)`;
884
- }
885
- function blockReasonSummary(reason, ctx) {
886
- switch (reason) {
887
- case 'cost_limit':
888
- // The engine writes a precise, limit-specific message into stepOutputs.blockMessage
889
- // (see src/loop/engine.ts cost check). This branch is only reached when that
890
- // structured message is missing — keep it vague so we do not claim the per-run
891
- // limit tripped when it might have been the daily cap.
892
- return `Cost limit exceeded for this run (estimated run cost: $${ctx.estimatedCostUsd.toFixed(4)})`;
893
- case 'iteration_limit':
894
- return `Maximum review iterations reached (${ctx.iteration}/${ctx.adjustedLimits.maxReviewIterations})`;
895
- case 'agent_pass_limit':
896
- return `Maximum total agent passes reached (${ctx.totalAgentPasses}/${ctx.adjustedLimits.maxTotalAgentPasses})`;
897
- case 'reviewer_blocked':
898
- return 'Reviewer marked this run as blocked';
899
- case 'ambiguous_review':
900
- return 'Review output was not parseable and blockOnAmbiguousReview is enabled';
901
- case 'verify_config':
902
- return 'Verification is required but verify commands or results are unavailable';
903
- case 'merge_conflict':
904
- return 'Merge conflict encountered while applying updates';
905
- case 'auth_failure':
906
- return 'Worker CLI authentication expired — re-authenticate and retry';
907
- default:
908
- return `Blocked in phase ${ctx.currentPhase}`;
909
- }
910
- }
911
- function formatBlockComment(reason, ctx) {
912
- const parts = [`⛔ **night-orch**: Run blocked.\n\n**Reason:** ${reason}`];
913
- if (ctx.reviewResult?.findings && ctx.reviewResult.findings.length > 0) {
914
- parts.push('\n**Findings:**');
915
- for (const f of ctx.reviewResult.findings) {
916
- const fix = f.suggestedFix ? ` → ${f.suggestedFix}` : '';
917
- parts.push(`- **${f.severity}**: ${f.message}${fix}`);
918
- }
919
- }
920
- parts.push(`\n*Iteration ${ctx.iteration}, cost: $${ctx.estimatedCostUsd.toFixed(4)}*`);
921
- return parts.join('\n');
922
- }
923
- function makePayload(event, repo, issue, extra = {}) {
924
- return {
925
- event,
926
- repo,
927
- issueNumber: issue.number,
928
- issueTitle: issue.title,
929
- issueUrl: issue.url ?? null,
930
- state: event,
931
- prUrl: null,
932
- prNumber: null,
933
- summary: `${event}: #${issue.number} ${issue.title}`,
934
- blockingReason: null,
935
- reviewSummary: null,
936
- iterationCount: 0,
937
- timestamp: nowUtcIso(),
938
- ...extra,
939
- };
940
- }
941
- async function postStatusComment(params) {
942
- const { forge, issueRepo, issueNumber, botUser, body, warnMessage, } = params;
943
- try {
944
- if (botUser) {
945
- await upsertBotComment(forge, issueRepo, issueNumber, STATUS_MARKER, body, botUser);
946
- }
947
- else {
948
- await forge.commentOnIssue(issueRepo, issueNumber, body);
949
- }
950
- }
951
- catch (commentErr) {
952
- logger.warn({ repo: issueRepo, issueNumber, err: commentErr }, warnMessage);
953
- }
954
- }
955
- const ERROR_COMMENT_MAX_LENGTH = 400;
956
- const TOKEN_REDACTION_PATTERNS = [
957
- /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
958
- /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
959
- /\bsk-[A-Za-z0-9]{20,}\b/g,
960
- /\bAKIA[0-9A-Z]{16}\b/g,
961
- /\bASIA[0-9A-Z]{16}\b/g,
962
- /\bAIza[0-9A-Za-z\-_]{20,}\b/g,
963
- /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/g,
964
- ];
965
- async function postErrorStatusComment(params) {
966
- const { forge, issueRepo, issueNumber, botUser, error, retryCount, maxRetries, nextStep, warnMessage, } = params;
967
- const sanitizedError = sanitizeErrorForComment(error);
968
- const body = formatStatusComment({
969
- error: sanitizedError,
970
- retryCount,
971
- maxRetries,
972
- nextStep,
973
- });
974
- await postStatusComment({
975
- forge,
976
- issueRepo,
977
- issueNumber,
978
- botUser,
979
- body,
980
- warnMessage,
981
- });
982
- }
983
- function toErrorMessage(err) {
984
- if (err instanceof Error && typeof err.message === 'string' && err.message.trim().length > 0) {
985
- return err.message;
986
- }
987
- return String(err);
988
- }
989
- function sanitizeErrorForComment(errorMessage) {
990
- let sanitized = errorMessage
991
- .replace(/[\r\n]+/g, ' ');
992
- sanitized = stripControlChars(sanitized);
993
- sanitized = sanitized
994
- .replace(/\b(token|secret|password|passwd|api[_-]?key)\s*[:=]\s*([^\s,;]+)/gi, '$1=[REDACTED]')
995
- .trim();
996
- for (const pattern of TOKEN_REDACTION_PATTERNS) {
997
- sanitized = sanitized.replace(pattern, '[REDACTED]');
998
- }
999
- sanitized = sanitized.replace(/\s+/g, ' ').trim();
1000
- if (!sanitized)
1001
- return 'unknown error';
1002
- const clipped = sanitized.length > ERROR_COMMENT_MAX_LENGTH
1003
- ? `${sanitized.slice(0, ERROR_COMMENT_MAX_LENGTH - 1)}…`
1004
- : sanitized;
1005
- return escapeMarkdownForComment(clipped);
1006
- }
1007
- function escapeMarkdownForComment(value) {
1008
- return value
1009
- .replace(/\\/g, '\\\\')
1010
- .replace(/([`*_#[\]])/g, '\\$1')
1011
- .replace(/</g, '&lt;')
1012
- .replace(/>/g, '&gt;')
1013
- .replace(/@/g, '@\u200B');
1014
- }
1015
- function stripControlChars(value) {
1016
- let out = '';
1017
- for (const ch of value) {
1018
- const code = ch.charCodeAt(0);
1019
- if ((code >= 0 && code <= 31) || code === 127) {
1020
- out += ' ';
1021
- continue;
1022
- }
1023
- out += ch;
1024
- }
1025
- return out;
1026
- }
1027
- /** Issues that returned 404 during comment scan in this process lifecycle.
1028
- * Bounded: entries are evicted when the key's run reaches a terminal state
1029
- * via {@link cleanupRunCaches}. */
1030
- const missingCommentCommandIssues = new Set();
1031
- function getHttpStatus(err) {
1032
- if (typeof err !== 'object' || err === null)
1033
- return null;
1034
- const e = err;
1035
- if (typeof e.status === 'number')
1036
- return e.status;
1037
- if (typeof e.response?.status === 'number')
1038
- return e.response.status;
1039
- return null;
1040
- }
1041
- async function processCommentCommands(params) {
1042
- const { config, db, forge, runManager, leaseManager, repoConfig, botUser, } = params;
1043
- const commandSettings = config.commentCommands ?? { enabled: true, requireCollaborator: false };
1044
- if (!commandSettings.enabled)
1045
- return;
1046
- // Warn once per poll when comment commands accept non-collaborators —
1047
- // for public repos this means any GitHub user can trigger retry/rebase/
1048
- // continue/delete operations.
1049
- if (!commandSettings.requireCollaborator) {
1050
- logger.warn({ repo: repoConfig.repo }, 'commentCommands.requireCollaborator=false — /orch commands accept any commenter. Enable on public repos.');
1051
- }
1052
- const activeRuns = runManager
1053
- .getActive()
1054
- .filter((run) => run.repo === repoConfig.repo);
1055
- const issueRows = [...new Map(activeRuns.map((run) => {
1056
- const issueRepo = resolveIssueRepo(run.phaseData, repoConfig.repo);
1057
- return [`${issueRepo}#${run.issueNumber}`, { issue_number: run.issueNumber, issue_repo: issueRepo }];
1058
- })).values()]
1059
- .sort((a, b) => a.issue_repo.localeCompare(b.issue_repo) || a.issue_number - b.issue_number);
1060
- if (issueRows.length === 0)
1061
- return;
1062
- const collaboratorCache = new Map();
1063
- for (const row of issueRows) {
1064
- const issueKey = `${row.issue_repo}#${row.issue_number}`;
1065
- if (missingCommentCommandIssues.has(issueKey)) {
1066
- continue;
1067
- }
1068
- let comments;
1069
- try {
1070
- comments = await forge.listIssueComments(row.issue_repo, row.issue_number);
1071
- }
1072
- catch (err) {
1073
- if (getHttpStatus(err) === 404) {
1074
- missingCommentCommandIssues.add(issueKey);
1075
- logger.debug({ repo: row.issue_repo, issueNumber: row.issue_number }, 'Skipping comment command scan for missing or inaccessible issue');
1076
- continue;
1077
- }
1078
- throw err;
1079
- }
1080
- const parsed = parseOrchCommands(comments, '1970-01-01T00:00:00Z');
1081
- for (const item of parsed) {
1082
- if (isCommandProcessed(db, row.issue_repo, row.issue_number, item.commentId))
1083
- continue;
1084
- // Track whether the command reached a terminal outcome — applied,
1085
- // denied (policy decision), or rejected (validated failure). Only
1086
- // terminal outcomes mark the command as processed; transient
1087
- // failures must remain retriable on the next poll cycle.
1088
- let commandStatus = null;
1089
- try {
1090
- const allowed = await canExecuteCommentCommand({
1091
- forge,
1092
- repo: row.issue_repo,
1093
- user: item.user,
1094
- requireCollaborator: commandSettings.requireCollaborator,
1095
- cache: collaboratorCache,
1096
- });
1097
- if (!allowed) {
1098
- commandStatus = 'denied';
1099
- logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, user: item.user, commentId: item.commentId }, 'Ignoring comment command from non-collaborator');
1100
- continue;
1101
- }
1102
- const result = await executeCommentCommand({
1103
- command: item.command,
1104
- db,
1105
- forge,
1106
- runManager,
1107
- leaseManager,
1108
- repoConfig,
1109
- issueRepo: row.issue_repo,
1110
- issueNumber: row.issue_number,
1111
- botUser,
1112
- user: item.user,
1113
- });
1114
- if (!result.ok) {
1115
- commandStatus = 'rejected';
1116
- logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, command: item.command.type, reason: result.reason }, 'Comment command rejected');
1117
- }
1118
- else {
1119
- commandStatus = 'applied';
1120
- logger.info({ repo: repoConfig.repo, issueNumber: row.issue_number, command: item.command.type, user: item.user }, 'Comment command applied');
1121
- }
1122
- }
1123
- catch (err) {
1124
- // Transient failure: leave commandStatus=null so the command
1125
- // remains unprocessed and will be retried on the next poll.
1126
- logger.warn({ repo: repoConfig.repo, issueNumber: row.issue_number, commentId: item.commentId, command: item.command.type, err }, 'Comment command failed (transient — will retry)');
1127
- }
1128
- finally {
1129
- if (commandStatus !== null) {
1130
- markCommandProcessed(db, row.issue_repo, row.issue_number, item.commentId, `${item.command.type}:${commandStatus}`);
1131
- }
1132
- }
1133
- }
1134
- }
1135
- }
1136
- async function canExecuteCommentCommand(params) {
1137
- const { forge, repo, user, requireCollaborator, cache } = params;
1138
- if (!requireCollaborator)
1139
- return true;
1140
- if (!user)
1141
- return false;
1142
- // Cache key must include the repo — a user might be a collaborator on
1143
- // one linked project but not another, and reusing a single-user cache
1144
- // across repos in the same scan would erroneously grant or deny access.
1145
- const cacheKey = `${repo}\n${user}`;
1146
- const cached = cache.get(cacheKey);
1147
- if (cached !== undefined)
1148
- return cached;
1149
- if (!forge.isCollaborator) {
1150
- logger.warn({ repo, user }, 'requireCollaborator=true but forge adapter has no isCollaborator() implementation');
1151
- cache.set(cacheKey, false);
1152
- return false;
1153
- }
1154
- try {
1155
- const allowed = await forge.isCollaborator(repo, user);
1156
- cache.set(cacheKey, allowed);
1157
- return allowed;
1158
- }
1159
- catch (err) {
1160
- logger.warn({ repo, user, err }, 'Failed collaborator check for comment command user');
1161
- cache.set(cacheKey, false);
1162
- return false;
1163
- }
1164
- }
1165
- async function executeCommentCommand(params) {
1166
- const { command, db, forge, runManager, leaseManager, repoConfig, issueRepo, issueNumber, botUser, user, } = params;
1167
- switch (command.type) {
1168
- case 'retry':
1169
- return queueRetryFromComment({
1170
- runManager,
1171
- leaseManager,
1172
- forge,
1173
- repoConfig,
1174
- issueRepo,
1175
- issueNumber,
1176
- resetPlan: command.resetPlan,
1177
- });
1178
- case 'continue':
1179
- {
1180
- const result = await queueContinue(db, forge, repoConfig, issueNumber, botUser, { issueRepo });
1181
- return result.queued ? { ok: true } : { ok: false, reason: result.reason };
1182
- }
1183
- case 'rebase': {
1184
- // queueRebase currently always verifies after rebase; keep behavior stable.
1185
- const result = await queueRebase(db, forge, repoConfig, issueNumber, botUser);
1186
- return result.queued ? { ok: true } : { ok: false, reason: result.reason };
1187
- }
1188
- case 'cancel':
1189
- return cancelRunFromComment({
1190
- runManager,
1191
- leaseManager,
1192
- forge,
1193
- repoConfig,
1194
- issueRepo,
1195
- issueNumber,
1196
- user,
1197
- });
1198
- default: {
1199
- const exhaustive = command;
1200
- return { ok: false, reason: `Unsupported command: ${String(exhaustive)}` };
1201
- }
1202
- }
1203
- }
1204
- async function queueRetryFromComment(params) {
1205
- const { runManager, leaseManager, forge, repoConfig, issueRepo, issueNumber, resetPlan } = params;
1206
- const run = runManager.getByRepoAndIssue(repoConfig.repo, issueNumber);
1207
- if (!run)
1208
- return { ok: false, reason: 'No run found for issue' };
1209
- if (run.status === 'running')
1210
- return { ok: false, reason: 'Run is currently running' };
1211
- if (!['blocked', 'error', 'review_ready'].includes(run.status)) {
1212
- return { ok: false, reason: `Retry not allowed from status ${run.status}` };
1213
- }
1214
- runManager.update(run.id, {
1215
- status: 'queued',
1216
- currentPhase: null,
1217
- endedAt: null,
1218
- lastError: null,
1219
- phaseData: resetPlan ? null : run.phaseData,
1220
- blockReason: null,
1221
- });
1222
- leaseManager.release(issueRepo, issueNumber);
1223
- if (issueRepo !== repoConfig.repo) {
1224
- leaseManager.release(repoConfig.repo, issueNumber);
1225
- }
1226
- const issue = await forge.getIssue(issueRepo, issueNumber);
1227
- await transitionLabels(forge, issueRepo, issueNumber, issue.labels, run.status, 'queued', buildLabelConfig(repoConfig, issue.labels));
1228
- return { ok: true };
1229
- }
1230
- async function cancelRunFromComment(params) {
1231
- const { runManager, leaseManager, forge, repoConfig, issueRepo, issueNumber, user } = params;
1232
- const run = runManager.getByRepoAndIssue(repoConfig.repo, issueNumber);
1233
- if (!run)
1234
- return { ok: false, reason: 'No run found for issue' };
1235
- if (run.status !== 'running' && run.status !== 'queued') {
1236
- return { ok: false, reason: `Cancel only supports running/queued runs (current: ${run.status})` };
1237
- }
1238
- runManager.update(run.id, {
1239
- status: 'blocked',
1240
- endedAt: nowUtcIso(),
1241
- lastError: `Cancelled by @${user} via comment command`,
1242
- blockReason: null,
1243
- });
1244
- leaseManager.release(issueRepo, issueNumber);
1245
- if (issueRepo !== repoConfig.repo) {
1246
- leaseManager.release(repoConfig.repo, issueNumber);
1247
- }
1248
- const issue = await forge.getIssue(issueRepo, issueNumber);
1249
- await transitionLabels(forge, issueRepo, issueNumber, issue.labels, run.status, 'blocked', buildLabelConfig(repoConfig, issue.labels));
1250
- return { ok: true };
1251
- }
1252
- /**
1253
- * Block reasons that indicate the coder's work is broken and the branch
1254
- * should be hard-reset to base on the next attempt. Salvageable states
1255
- * (reviewer_blocked, iteration_limit, ambiguous_review, verify_config)
1256
- * preserve the branch so existing work can be continued.
1257
- */
1258
- const TAINTED_BLOCK_REASONS = new Set(['agent_pass_limit', 'cost_limit', 'merge_conflict', 'auth_failure']);
1259
- // --- Reaction scanning ---
1260
- /** In-memory reaction cursors, keyed by "repo#issueNumber".
1261
- * Bounded: entries are evicted via {@link cleanupRunCaches}. */
1262
- const reactionCursors = new Map();
1263
- /**
1264
- * Evict entries from process-global caches that are keyed by repo+issue.
1265
- * Called when a run reaches a terminal state so the caches don't grow
1266
- * unbounded over the daemon's lifetime.
1267
- */
1268
- function cleanupRunCaches(repo, issueNumber) {
1269
- const key = `${repo}#${issueNumber}`;
1270
- missingCommentCommandIssues.delete(key);
1271
- reactionCursors.delete(key);
1272
- }
1273
- async function scanAndHandleReactions(params) {
1274
- const { db, forge, runManager, repoConfig, botUser } = params;
1275
- // Find review_ready issues with PRs for this repo.
1276
- const rows = runManager
1277
- .getActive()
1278
- .filter((run) => run.repo === repoConfig.repo && run.status === 'review_ready' && run.prNumber !== null)
1279
- .map((run) => ({
1280
- id: run.id,
1281
- repo: run.repo,
1282
- issue_number: run.issueNumber,
1283
- pr_number: run.prNumber,
1284
- }));
1285
- for (const row of rows) {
1286
- const cursorKey = `${row.repo}#${row.issue_number}`;
1287
- const cursor = reactionCursors.get(cursorKey);
1288
- const result = await scanForReactions(forge, row.repo, row.pr_number, row.issue_number, botUser, cursor);
1289
- // Update cursor regardless of reactions
1290
- reactionCursors.set(cursorKey, result.cursor);
1291
- // Handle each reaction
1292
- for (const reaction of result.reactions) {
1293
- try {
1294
- await handleReaction(reaction, { db, forge, runManager, repoConfig });
1295
- }
1296
- catch (err) {
1297
- logger.warn({ repo: row.repo, issueNumber: row.issue_number, reactionType: reaction.type, err }, 'Failed to handle reaction');
1298
- }
1299
- }
1300
- }
1301
- }
1302
- function extractFollowupPromptFeedback(phaseData) {
1303
- if (!phaseData)
1304
- return null;
1305
- const context = phaseData['reactionContext'];
1306
- if (typeof context !== 'string' || context.trim().length === 0)
1307
- return null;
1308
- const type = typeof phaseData['reactionType'] === 'string' && phaseData['reactionType'].trim().length > 0
1309
- ? phaseData['reactionType']
1310
- : 'continue';
1311
- const summary = typeof phaseData['reactionSummary'] === 'string' && phaseData['reactionSummary'].trim().length > 0
1312
- ? phaseData['reactionSummary']
1313
- : 'Follow-up context available';
1314
- return { type, summary, context };
1315
- }
1316
- /**
1317
- * Prioritize follow-up work over fresh issues so reactive runs (especially
1318
- * merge conflict rebases) are handled promptly and don't starve behind newer
1319
- * ready issues.
1320
- */
1321
- function prioritizeDiscoveredIssues(runManager, repo, discovered) {
1322
- const ranked = discovered.map((item) => ({
1323
- item,
1324
- rank: getIssueQueuePriority(runManager, repo, item.issue.number),
1325
- }));
1326
- ranked.sort((a, b) => a.rank - b.rank);
1327
- return ranked.map((entry) => entry.item);
1328
- }
1329
- function getIssueQueuePriority(runManager, repo, issueNumber) {
1330
- const queuedRun = runManager.getLatestQueuedByIssue(repo, issueNumber);
1331
- if (!queuedRun)
1332
- return 3;
1333
- const reactionType = queuedRun.phaseData?.reactionType;
1334
- if (reactionType === 'merge_conflict' || reactionType === 'rebase')
1335
- return 0;
1336
- if (typeof reactionType === 'string' && reactionType.length > 0)
1337
- return 1;
1338
- return 2;
1339
- }
1340
- function selectReplayableRun(run) {
1341
- if (!run)
1342
- return null;
1343
- if (run.status === 'blocked' || run.status === 'review_ready' || run.status === 'error') {
1344
- return run;
1345
- }
1346
- return null;
1347
- }
1348
- function shouldResetBranch(runManager, repo, issueNumber, currentRunId) {
1349
- const prior = runManager.getLatestFinishedByIssue(repo, issueNumber, currentRunId);
1350
- if (!prior)
1351
- return false;
1352
- // Infrastructure errors — work is unreliable
1353
- if (prior.status === 'error')
1354
- return true;
1355
- // Blocked with tainted block reason — coder couldn't produce working code
1356
- if (prior.status === 'blocked' && prior.blockReason && TAINTED_BLOCK_REASONS.has(prior.blockReason))
1357
- return true;
1358
- return false;
1359
- }
1360
598
  //# sourceMappingURL=poller.js.map