@urateam/core 0.1.31 → 0.1.32

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 (101) hide show
  1. package/dist/__tests__/agent-stream.test.js +35 -1
  2. package/dist/__tests__/agent-stream.test.js.map +1 -1
  3. package/dist/__tests__/control-signals.test.d.ts +2 -0
  4. package/dist/__tests__/control-signals.test.d.ts.map +1 -0
  5. package/dist/__tests__/control-signals.test.js +77 -0
  6. package/dist/__tests__/control-signals.test.js.map +1 -0
  7. package/dist/__tests__/pm-slack-interface.test.js +45 -0
  8. package/dist/__tests__/pm-slack-interface.test.js.map +1 -1
  9. package/dist/__tests__/pm-triage.test.js +101 -0
  10. package/dist/__tests__/pm-triage.test.js.map +1 -1
  11. package/dist/__tests__/util-linear.test.d.ts +10 -0
  12. package/dist/__tests__/util-linear.test.d.ts.map +1 -0
  13. package/dist/__tests__/util-linear.test.js +244 -0
  14. package/dist/__tests__/util-linear.test.js.map +1 -0
  15. package/dist/audit/events.d.ts +24 -0
  16. package/dist/audit/events.d.ts.map +1 -1
  17. package/dist/audit/events.js +32 -0
  18. package/dist/audit/events.js.map +1 -1
  19. package/dist/executor/agent-stream.d.ts +16 -0
  20. package/dist/executor/agent-stream.d.ts.map +1 -1
  21. package/dist/executor/agent-stream.js +43 -1
  22. package/dist/executor/agent-stream.js.map +1 -1
  23. package/dist/executor/executor.d.ts.map +1 -1
  24. package/dist/executor/executor.js +24 -5
  25. package/dist/executor/executor.js.map +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/notifier/linear.d.ts +0 -1
  31. package/dist/notifier/linear.d.ts.map +1 -1
  32. package/dist/notifier/linear.js +2 -5
  33. package/dist/notifier/linear.js.map +1 -1
  34. package/dist/pipeline/control-signals.d.ts +49 -0
  35. package/dist/pipeline/control-signals.d.ts.map +1 -0
  36. package/dist/pipeline/control-signals.js +93 -0
  37. package/dist/pipeline/control-signals.js.map +1 -0
  38. package/dist/pipeline/feedback-pipeline.d.ts +140 -0
  39. package/dist/pipeline/feedback-pipeline.d.ts.map +1 -0
  40. package/dist/pipeline/feedback-pipeline.js +427 -0
  41. package/dist/pipeline/feedback-pipeline.js.map +1 -0
  42. package/dist/pipeline/index.d.ts +1 -0
  43. package/dist/pipeline/index.d.ts.map +1 -1
  44. package/dist/pipeline/index.js +1 -0
  45. package/dist/pipeline/index.js.map +1 -1
  46. package/dist/pipeline/runner.d.ts +49 -33
  47. package/dist/pipeline/runner.d.ts.map +1 -1
  48. package/dist/pipeline/runner.js +136 -349
  49. package/dist/pipeline/runner.js.map +1 -1
  50. package/dist/pm/actions/promote.d.ts.map +1 -1
  51. package/dist/pm/actions/promote.js +15 -11
  52. package/dist/pm/actions/promote.js.map +1 -1
  53. package/dist/pm/actions/recover-stuck.d.ts.map +1 -1
  54. package/dist/pm/actions/recover-stuck.js +9 -5
  55. package/dist/pm/actions/recover-stuck.js.map +1 -1
  56. package/dist/pm/actions/triage.d.ts.map +1 -1
  57. package/dist/pm/actions/triage.js +67 -1
  58. package/dist/pm/actions/triage.js.map +1 -1
  59. package/dist/pm/linear-helpers.d.ts +10 -0
  60. package/dist/pm/linear-helpers.d.ts.map +1 -1
  61. package/dist/pm/linear-helpers.js +13 -21
  62. package/dist/pm/linear-helpers.js.map +1 -1
  63. package/dist/pm/pause-state.d.ts +29 -0
  64. package/dist/pm/pause-state.d.ts.map +1 -0
  65. package/dist/pm/pause-state.js +34 -0
  66. package/dist/pm/pause-state.js.map +1 -0
  67. package/dist/pm/slack-bulk.d.ts +34 -0
  68. package/dist/pm/slack-bulk.d.ts.map +1 -0
  69. package/dist/pm/slack-bulk.js +110 -0
  70. package/dist/pm/slack-bulk.js.map +1 -0
  71. package/dist/pm/slack-commands.d.ts +101 -0
  72. package/dist/pm/slack-commands.d.ts.map +1 -0
  73. package/dist/pm/slack-commands.js +309 -0
  74. package/dist/pm/slack-commands.js.map +1 -0
  75. package/dist/pm/slack-helpers.d.ts +10 -0
  76. package/dist/pm/slack-helpers.d.ts.map +1 -1
  77. package/dist/pm/slack-helpers.js +32 -0
  78. package/dist/pm/slack-helpers.js.map +1 -1
  79. package/dist/pm/slack-interface.d.ts +32 -58
  80. package/dist/pm/slack-interface.d.ts.map +1 -1
  81. package/dist/pm/slack-interface.js +150 -320
  82. package/dist/pm/slack-interface.js.map +1 -1
  83. package/dist/rbac/matrix.d.ts +2 -0
  84. package/dist/rbac/matrix.d.ts.map +1 -1
  85. package/dist/rbac/matrix.js +2 -0
  86. package/dist/rbac/matrix.js.map +1 -1
  87. package/dist/server.d.ts.map +1 -1
  88. package/dist/server.js +7 -0
  89. package/dist/server.js.map +1 -1
  90. package/dist/sync/gh-linear-sync.d.ts.map +1 -1
  91. package/dist/sync/gh-linear-sync.js +2 -2
  92. package/dist/sync/gh-linear-sync.js.map +1 -1
  93. package/dist/types.d.ts +13 -4
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +6 -0
  96. package/dist/types.js.map +1 -1
  97. package/dist/util/linear.d.ts +70 -0
  98. package/dist/util/linear.d.ts.map +1 -0
  99. package/dist/util/linear.js +108 -0
  100. package/dist/util/linear.js.map +1 -0
  101. package/package.json +1 -1
@@ -7,6 +7,8 @@ import { checkRequirements, buildRalphContext } from "../executor/ralph.js";
7
7
  import { computeEffectiveRalphIterations } from "./runner-ralph-helpers.js";
8
8
  import { checkTestQuality } from "../executor/test-quality.js";
9
9
  import { buildDeepReviewContext } from "../executor/deep-review.js";
10
+ import { getStopSignal, requestStop, clearStopSignal } from "./control-signals.js";
11
+ import { setPmPaused } from "../pm/pause-state.js";
10
12
  import { runReviewProviders } from "./review-providers-runner.js";
11
13
  import { postFanoutCommentsToPR } from "../executor/review/post-fanout-comments.js";
12
14
  import { extractHandoff } from "../executor/extract-handoff.js";
@@ -19,8 +21,8 @@ import { homedir } from "node:os";
19
21
  import { execFile as execFileCb } from "node:child_process";
20
22
  import { promisify } from "node:util";
21
23
  const execFileAsync = promisify(execFileCb);
22
- import { cloneRepo, createWorktree, deleteWorktree, pushBranch, pushBranchForce, choosePushStrategy, rebaseBranch, abortRebase, autoCommitChanges, getAgentCommits, createPRViaCli, mergePRViaCli, getDiffLineCount, getChangedFiles, checkDuplicateBranch, branchName, createWorktreeFromRemote, pruneWorktreesInRepoDirs, } from "../repo/git.js";
23
- import { addPRComment, createGitHubClient, createPR, prHasCommentStartingWith, rerequestPRReview, } from "../repo/github.js";
24
+ import { cloneRepo, createWorktree, deleteWorktree, pushBranch, pushBranchForce, choosePushStrategy, rebaseBranch, abortRebase, autoCommitChanges, getAgentCommits, createPRViaCli, mergePRViaCli, getDiffLineCount, getChangedFiles, checkDuplicateBranch, branchName, pruneWorktreesInRepoDirs, } from "../repo/git.js";
25
+ import { addPRComment, createGitHubClient, createPR, prHasCommentStartingWith, } from "../repo/github.js";
24
26
  import { createMR, buildAuthenticatedUrl, } from "../repo/gitlab.js";
25
27
  import { parseRepoUrl, parseGitLabUrl } from "../repo/config.js";
26
28
  import { detectTechStack } from "../repo/tech-stack.js";
@@ -36,35 +38,13 @@ import { evaluatePolicyGates } from "../policy/evaluate.js";
36
38
  import { buildReviewerRequest, verifyApprovalsReceived } from "../policy/index.js";
37
39
  import { logAuditEvent, policyReviewersRequestedEvent, reviewFanoutFallbackUsedEvent } from "../audit/index.js";
38
40
  import { matchesAnyPattern } from "../util/glob.js";
41
+ import { startFeedbackPipeline, } from "./feedback-pipeline.js";
42
+ // Re-export from extracted module so existing callers (including tests) still
43
+ // find buildReviewFeedbackContext at pipeline/runner.js without changing their
44
+ // import paths.
45
+ export { buildReviewFeedbackContext } from "./feedback-pipeline.js";
39
46
  // Module-level logger (no runId yet — used for pre-run messages)
40
47
  const log = createLogger({ component: "PipelineRunner" });
41
- /**
42
- * Map webhook-shaped `ReviewFeedbackComment[]` (the wire format we receive
43
- * from GitHub) into the `ReviewFeedbackContext` that the implement template
44
- * expects when handling PR review feedback.
45
- *
46
- * Routes the implement stage into the dedicated review-feedback prompt path
47
- * (templates.ts:233-253) — "address review comments on existing branch, push
48
- * to same branch, do NOT create a new PR" — instead of falling through to
49
- * the standard "create branch and implement issue from scratch" prompt.
50
- *
51
- * `createdAt` is not captured by the GitHub webhook handler today, so the
52
- * mapped comments use an empty string. The template only renders this for
53
- * display; an empty value is harmless.
54
- */
55
- export function buildReviewFeedbackContext(prUrl, prBranch, comments) {
56
- return {
57
- prUrl,
58
- prBranch,
59
- comments: comments.map((c) => ({
60
- author: c.author,
61
- body: c.body,
62
- file: c.filePath,
63
- line: c.lineNumber,
64
- createdAt: "",
65
- })),
66
- };
67
- }
68
48
  export class PipelineRunner {
69
49
  queue;
70
50
  /** Push queue: concurrency=1 serialises push+PR creation within this process.
@@ -330,6 +310,83 @@ export class PipelineRunner {
330
310
  .where(eq(pipelineRuns.id, runId));
331
311
  this.activeRuns.delete(issueId);
332
312
  }
313
+ /**
314
+ * Operator-initiated stop for a single run, addressed by runId.
315
+ *
316
+ * - `"cancel"` aborts the active Agent SDK stream immediately. The current
317
+ * stage exits with `status: "cancelled"`; the pipeline marks the run
318
+ * `cancelled` and returns without creating a PR.
319
+ * - `"graceful"` lets the current stage complete, then skips remaining
320
+ * stages. Slower than cancel but leaves the worktree consistent.
321
+ *
322
+ * Idempotent. Returns the issueId resolved from the active map (or null if
323
+ * the runId isn't currently active). The caller is responsible for emitting
324
+ * the audit event since it knows the actor.
325
+ */
326
+ requestStop(runId, mode) {
327
+ let issueId = null;
328
+ for (const [iss, rid] of this.activeRuns) {
329
+ if (rid === runId) {
330
+ issueId = iss;
331
+ break;
332
+ }
333
+ }
334
+ const effective = requestStop(runId, mode);
335
+ return { issueId, mode: effective };
336
+ }
337
+ /**
338
+ * Halt the whole container's autonomous work:
339
+ * 1. Pauses the PM Agent (BEC-170 mechanism) so no new runs get promoted.
340
+ * 2. Sends a `"cancel"` signal to every active pipeline + feedback run.
341
+ *
342
+ * Returns the set of run ids that were cancelled — the caller emits the
343
+ * audit event. Reversible: PM Agent can be unpaused via `/pm resume` and
344
+ * individual runs can be re-triggered via the retry button. Cancelled runs
345
+ * stay cancelled.
346
+ */
347
+ haltAll() {
348
+ setPmPaused(true);
349
+ const cancelled = new Set();
350
+ for (const runId of this.activeRuns.values()) {
351
+ requestStop(runId, "cancel");
352
+ cancelled.add(runId);
353
+ }
354
+ for (const runId of this.activeFeedbackRuns.values()) {
355
+ requestStop(runId, "cancel");
356
+ cancelled.add(runId);
357
+ }
358
+ log.info({ count: cancelled.size, runIds: [...cancelled] }, "haltAll: PM paused and cancel signals sent to active runs");
359
+ return { cancelledRunIds: [...cancelled] };
360
+ }
361
+ /**
362
+ * Mark a run as cancelled in the DB and clean up bookkeeping. Shared by the
363
+ * pre-stage graceful path and the mid-stream cancel path.
364
+ *
365
+ * For feedback-pipeline runs, pass `feedbackPrUrl` so the per-PR rate-limit
366
+ * slot is freed immediately rather than waiting on the queue's `finally`
367
+ * block. The queue's `finally` still fires (idempotent — Map.delete on a
368
+ * missing key is a no-op), this just shortens the window during which a new
369
+ * feedback comment on the same PR is rejected as "already active".
370
+ */
371
+ markRunCancelled(db, runId, run, mode, feedbackPrUrl) {
372
+ return this.markRunCancelledImpl(db, runId, run, mode, feedbackPrUrl);
373
+ }
374
+ async markRunCancelledImpl(db, runId, run, mode, feedbackPrUrl) {
375
+ await removeActiveWork(db, runId);
376
+ await db
377
+ .update(pipelineRuns)
378
+ .set({
379
+ status: "cancelled",
380
+ errorMessage: `cancelled by operator (${mode})`,
381
+ completedAt: new Date(),
382
+ })
383
+ .where(eq(pipelineRuns.id, runId));
384
+ run.status = "cancelled";
385
+ this.activeRuns.delete(run.issueId);
386
+ if (feedbackPrUrl)
387
+ this.activeFeedbackRuns.delete(feedbackPrUrl);
388
+ clearStopSignal(runId);
389
+ }
333
390
  isActive(issueId) {
334
391
  return this.activeRuns.has(issueId);
335
392
  }
@@ -340,77 +397,31 @@ export class PipelineRunner {
340
397
  /**
341
398
  * Start a review-feedback pipeline run triggered by a PR review comment.
342
399
  *
343
- * Unlike start(), this method:
344
- * - Does NOT create a new branch — it checks out the existing PR branch.
345
- * - Skips triage and reproduce stages, entering directly at implement.
346
- * - Does NOT create a new PR — it pushes to the same branch.
347
- * - Optionally re-requests review after pushing.
400
+ * Thin wrapper — all orchestration logic (rate-limiting, DB insert, queue
401
+ * management) and execution are delegated to feedback-pipeline.ts.
402
+ * See startFeedbackPipeline() for full documentation.
348
403
  */
349
404
  async startFeedback(params) {
350
- const { issue, pipelineKey, pipelineConfig, repoConfig, sanitizedIssue, branch, prUrl, prNumber, parentRunId, feedbackComments, rerequestReview, } = params;
351
- log.info({ issueId: issue.identifier, pipeline: pipelineKey, prUrl }, "startFeedback() called");
352
- // Rate-limit: one feedback run per PR at a time
353
- if (this.activeFeedbackRuns.has(prUrl)) {
354
- log.info({ prUrl }, "feedback run already active for this PR — skipping");
355
- return;
356
- }
357
- const runId = nanoid();
358
- const db = this.db;
359
- const runLog = createLogger({
360
- component: "PipelineRunner",
361
- runId,
362
- issueId: issue.identifier,
363
- });
364
- // Copy linearTeamId from the parent run (if any) for spend-cap accounting.
365
- let linearTeamId = null;
366
- if (parentRunId) {
367
- const parentRows = await db
368
- .select({ linearTeamId: pipelineRuns.linearTeamId })
369
- .from(pipelineRuns)
370
- .where(eq(pipelineRuns.id, parentRunId))
371
- .limit(1);
372
- linearTeamId = parentRows[0]?.linearTeamId ?? null;
373
- }
374
- runLog.info({ branch, prUrl }, "inserting feedback run into DB");
375
- await db.insert(pipelineRuns).values({
376
- id: runId,
377
- issueId: issue.identifier,
378
- issueTitle: issue.title,
379
- pipelineKey,
380
- repoUrl: repoConfig.url,
381
- branch,
382
- status: "queued",
383
- prUrl,
384
- runType: "review-feedback",
385
- parentRunId: parentRunId ?? null,
386
- feedbackContext: JSON.stringify(feedbackComments),
387
- linearTeamId,
388
- });
389
- const run = this.buildPipelineRun(runId, issue, pipelineKey, repoConfig, branch);
390
- run.prUrl = prUrl;
391
- run.runType = "review-feedback";
392
- run.feedbackContext = JSON.stringify(feedbackComments);
393
- // Register in activeFeedbackRuns BEFORE enqueue so rate-limit check works
394
- this.activeFeedbackRuns.set(prUrl, runId);
395
- this.queue
396
- .enqueue(async () => {
397
- if (!this.activeFeedbackRuns.has(prUrl))
398
- return; // was cancelled
399
- runLog.info("executing feedback pipeline");
400
- try {
401
- await runWithLogContext({ runId, issueId: issue.identifier }, () => this.executeFeedbackPipeline(runId, run, pipelineConfig, repoConfig, sanitizedIssue, branch, prUrl, prNumber, feedbackComments, rerequestReview ?? false));
402
- }
403
- catch (err) {
404
- runLog.error({ err }, "feedback pipeline execution failed");
405
- }
406
- finally {
407
- this.activeFeedbackRuns.delete(prUrl);
408
- }
409
- })
410
- .catch((err) => {
411
- runLog.error({ err }, "feedback queue execution failed");
412
- this.activeFeedbackRuns.delete(prUrl);
413
- });
405
+ const ctx = {
406
+ db: this.db,
407
+ notifier: this.notifier,
408
+ repoCloneDir: this.repoCloneDir,
409
+ agentRunDir: this.agentRunDir,
410
+ githubConfig: this.githubConfig,
411
+ gitlabConfig: this.gitlabConfig,
412
+ pushQueue: this.pushQueue,
413
+ lockAdapter: this.lockAdapter,
414
+ prLockTimeoutMs: this.prLockTimeoutMs,
415
+ budgetAlertedRuns: this.budgetAlertedRuns,
416
+ checkTokenBudget: this.checkTokenBudget.bind(this),
417
+ failPipeline: this.failPipeline.bind(this),
418
+ injectAgentConfig: this.injectAgentConfig.bind(this),
419
+ markRunCancelled: this.markRunCancelled.bind(this),
420
+ activeFeedbackRuns: this.activeFeedbackRuns,
421
+ queue: this.queue,
422
+ buildPipelineRun: this.buildPipelineRun.bind(this),
423
+ };
424
+ return startFeedbackPipeline(ctx, params);
414
425
  }
415
426
  /**
416
427
  * Inject the framework's CLAUDE.md into the worktree if the repo doesn't
@@ -564,6 +575,16 @@ export class PipelineRunner {
564
575
  const stageType = stage;
565
576
  lastStageIndex = config.stages.indexOf(stage);
566
577
  runLog.info({ stage: stageType }, "executing stage");
578
+ // Operator stop check (graceful path) — fires between stages so the
579
+ // previous stage's work is preserved. The "cancel" path is interrupted
580
+ // mid-stream by the executor's AbortController and surfaces below as
581
+ // result.status === "cancelled".
582
+ const preStageStopSignal = getStopSignal(runId);
583
+ if (preStageStopSignal) {
584
+ runLog.info({ stage: stageType, mode: preStageStopSignal }, "pipeline stop requested — aborting remaining stages");
585
+ await this.markRunCancelled(db, runId, run, preStageStopSignal);
586
+ return;
587
+ }
567
588
  if (stageType === "await-approval") {
568
589
  // Save the full resume context so resume() can re-attach the worktree
569
590
  // and continue from the next stage with the correct handoff artifact.
@@ -619,6 +640,16 @@ export class PipelineRunner {
619
640
  devcontainerSession,
620
641
  stageModels: config.stageModels,
621
642
  });
643
+ // Operator stop check (cancel path) — the AbortController inside the
644
+ // executor surfaces as result.status === "cancelled". Don't try to use
645
+ // the (possibly partial) handoff; exit immediately like the pre-stage
646
+ // graceful check above.
647
+ if (result.status === "cancelled") {
648
+ const mode = getStopSignal(runId) ?? "cancel";
649
+ runLog.info({ stage: stageType, mode }, "stage cancelled by operator — aborting pipeline");
650
+ await this.markRunCancelled(db, runId, run, mode);
651
+ return;
652
+ }
622
653
  // BEC-134: track the most recent review stage_run id so fanout
623
654
  // persistence can reuse it.
624
655
  if (stageType === "review") {
@@ -884,18 +915,15 @@ export class PipelineRunner {
884
915
  }
885
916
  }
886
917
  }
887
- // After stage completes: update coordination with actual files modified
888
- // so other agents can check for overlaps before starting their next stage.
918
+ // After stage completes: update the in-memory file list so the next
919
+ // stage's pre-loop upsertActiveWork persists the accumulated set.
920
+ // We intentionally skip a second DB write here — the row was already
921
+ // written at the start of this stage and will be refreshed again at
922
+ // the start of the next one, avoiding a redundant intermediate write.
889
923
  if (worktreePath) {
890
924
  const freshFiles = await getModifiedFiles(worktreePath);
891
925
  if (freshFiles.length > 0) {
892
926
  allModifiedFiles = freshFiles;
893
- await upsertActiveWork(db, {
894
- runId,
895
- issueId: sanitizedIssue.id,
896
- stage: stageType,
897
- filesModified: allModifiedFiles,
898
- });
899
927
  }
900
928
  }
901
929
  }
@@ -1684,6 +1712,8 @@ export class PipelineRunner {
1684
1712
  ? parseInt(summaryPrMatch[1], 10)
1685
1713
  : null;
1686
1714
  if (summaryPrNumber !== null) {
1715
+ // Fetch stage runs first; reviewModelRuns query depends on the
1716
+ // resulting stageIds so the two queries are necessarily sequential.
1687
1717
  const stages = await this.db
1688
1718
  .select()
1689
1719
  .from(stageRuns)
@@ -2004,249 +2034,6 @@ export class PipelineRunner {
2004
2034
  };
2005
2035
  await this.notifier.onDailyTokenSummary?.(summary);
2006
2036
  }
2007
- /**
2008
- * Execute a review-feedback pipeline run.
2009
- *
2010
- * Key differences from executePipeline():
2011
- * - Checks out the EXISTING PR branch (not a new one).
2012
- * - Skips triage, reproduce, await-approval stages.
2013
- * - Does NOT create a new PR — pushes to the same branch.
2014
- * - Optionally re-requests review via GitHub App after push.
2015
- * - Feedback comment context is injected into the implement stage prompt.
2016
- */
2017
- async executeFeedbackPipeline(runId, run, config, repoConfig, sanitizedIssue, branch, prUrl, prNumber, feedbackComments, rerequestReview) {
2018
- const db = this.db;
2019
- const runLog = createLogger({
2020
- component: "PipelineRunner",
2021
- runId,
2022
- issueId: run.issueId,
2023
- });
2024
- let worktreePath;
2025
- let devcontainerSession;
2026
- await db
2027
- .update(pipelineRuns)
2028
- .set({ status: "running" })
2029
- .where(eq(pipelineRuns.id, runId));
2030
- run.status = "running";
2031
- await upsertActiveWork(db, {
2032
- runId,
2033
- issueId: sanitizedIssue.id,
2034
- stage: "implement",
2035
- });
2036
- await this.notifier.onPipelineStart(run);
2037
- try {
2038
- // -----------------------------------------------------------------------
2039
- // Set up worktree from existing remote branch
2040
- // -----------------------------------------------------------------------
2041
- const repoDir = `${this.repoCloneDir}/${sanitizedIssue.slug}`;
2042
- const cloneUrl = repoConfig.provider === "gitlab" && this.gitlabConfig
2043
- ? buildAuthenticatedUrl(repoConfig.url, this.gitlabConfig)
2044
- : repoConfig.url;
2045
- const logUrl = cloneUrl.replace(/:\/\/[^@]+@/, "://[redacted]@");
2046
- runLog.info({ repoUrl: logUrl, repoDir }, "feedback: cloning/fetching repository");
2047
- await cloneRepo(cloneUrl, repoDir);
2048
- runLog.info({ branch }, "feedback: creating worktree from existing remote branch");
2049
- worktreePath = await createWorktreeFromRemote(repoDir, runId, branch, this.agentRunDir);
2050
- runLog.info({ worktreePath }, "feedback: worktree created");
2051
- // Devcontainer (if configured)
2052
- const useDevcontainer = await shouldUseDevcontainer(worktreePath, repoConfig.devcontainer);
2053
- if (useDevcontainer) {
2054
- runLog.info("feedback: starting devcontainer");
2055
- devcontainerSession = await devcontainerUp(worktreePath, repoConfig.devcontainer);
2056
- }
2057
- await this.injectAgentConfig(worktreePath);
2058
- if (repoConfig.setupCommands) {
2059
- for (const cmdArgs of repoConfig.setupCommands) {
2060
- const [command, ...args] = cmdArgs;
2061
- runLog.info({ command, args }, "feedback: running setup command");
2062
- try {
2063
- await execFileAsync(command, args, { cwd: worktreePath });
2064
- }
2065
- catch (err) {
2066
- const msg = err instanceof Error ? err.message : String(err);
2067
- runLog.error({ command, args, err }, "feedback: setup command failed");
2068
- throw new Error(`Setup command failed: ${command} ${args.join(" ")} — ${msg}`);
2069
- }
2070
- }
2071
- }
2072
- const techStack = await detectTechStack(worktreePath);
2073
- runLog.info({
2074
- languages: techStack.languages,
2075
- frameworks: techStack.frameworks,
2076
- buildSystems: techStack.buildSystems,
2077
- }, "feedback: tech stack detected");
2078
- // -----------------------------------------------------------------------
2079
- // Build review-feedback context for the implement stage. This routes the
2080
- // implement template into its dedicated review-feedback branch
2081
- // ("address comments on existing branch, push to same branch") instead
2082
- // of the standard "create new branch + implement from scratch" path,
2083
- // which is wrong for PR-comment triggered runs.
2084
- // -----------------------------------------------------------------------
2085
- const reviewFeedback = buildReviewFeedbackContext(prUrl, branch, feedbackComments);
2086
- // -----------------------------------------------------------------------
2087
- // Execute pipeline stages — skip triage, reproduce, await-approval
2088
- // -----------------------------------------------------------------------
2089
- const skipStages = new Set(["triage", "reproduce", "await-approval"]);
2090
- const stagesToRun = config.stages.filter((s) => !skipStages.has(s));
2091
- runLog.info({ stages: stagesToRun }, "feedback: starting pipeline stages");
2092
- let handoff;
2093
- let allModifiedFiles = [];
2094
- for (const stage of stagesToRun) {
2095
- const stageType = stage;
2096
- runLog.info({ stage: stageType }, "feedback: executing stage");
2097
- await upsertActiveWork(db, {
2098
- runId,
2099
- issueId: sanitizedIssue.id,
2100
- stage: stageType,
2101
- filesModified: allModifiedFiles.length > 0 ? allModifiedFiles : undefined,
2102
- });
2103
- // Only the implement stage uses reviewFeedback; the test/review stages
2104
- // get their context from the implement stage's handoff.
2105
- const stageReviewFeedback = stageType === "implement" ? reviewFeedback : undefined;
2106
- let result = await executeStage({
2107
- runId,
2108
- issueId: sanitizedIssue.id,
2109
- stage: stageType,
2110
- sanitizedIssue,
2111
- repoConfig,
2112
- handoff,
2113
- workdir: worktreePath,
2114
- db: this.db,
2115
- techStack,
2116
- devcontainerSession,
2117
- reviewFeedback: stageReviewFeedback,
2118
- stageModels: config.stageModels,
2119
- });
2120
- run.totalInputTokens += result.inputTokens;
2121
- run.totalOutputTokens += result.outputTokens;
2122
- if (await this.checkTokenBudget(db, runId, run, config, stage))
2123
- return;
2124
- await this.notifier.onStageComplete(run, stage, result);
2125
- if (result.status === "failed") {
2126
- const errorMsg = result.errorMessage ?? "Stage failed";
2127
- await this.failPipeline(db, runId, run, stage, errorMsg, false);
2128
- return;
2129
- }
2130
- handoff = result.handoffArtifact;
2131
- if (await autoCommitChanges(worktreePath, sanitizedIssue.id, branch)) {
2132
- run.autoCommitted = true;
2133
- }
2134
- if (worktreePath) {
2135
- const freshFiles = await getModifiedFiles(worktreePath);
2136
- if (freshFiles.length > 0) {
2137
- allModifiedFiles = freshFiles;
2138
- await upsertActiveWork(db, {
2139
- runId,
2140
- issueId: sanitizedIssue.id,
2141
- stage: stageType,
2142
- filesModified: allModifiedFiles,
2143
- });
2144
- }
2145
- }
2146
- }
2147
- // -----------------------------------------------------------------------
2148
- // Push to existing branch — no new PR
2149
- // -----------------------------------------------------------------------
2150
- await this.pushQueue.enqueue(async () => {
2151
- await withBranchLock(this.lockAdapter, branch, this.prLockTimeoutMs, async () => {
2152
- const wtPath = worktreePath;
2153
- if (await autoCommitChanges(wtPath, sanitizedIssue.id, branch)) {
2154
- run.autoCommitted = true;
2155
- }
2156
- runLog.info({ defaultBranch: repoConfig.defaultBranch }, "feedback push: rebasing before push");
2157
- const rebaseResult = await rebaseBranch(wtPath, repoConfig.defaultBranch);
2158
- const feedbackHasConflicts = !rebaseResult.success && rebaseResult.hasConflicts;
2159
- if (feedbackHasConflicts) {
2160
- runLog.warn("feedback push: rebase conflicts — force-pushing for human review");
2161
- await abortRebase(wtPath);
2162
- await pushBranchForce(wtPath, branch);
2163
- await this.notifier.onHumanReviewNeeded?.(run, prUrl, "Merge conflicts in feedback run — please resolve manually");
2164
- }
2165
- else {
2166
- const feedbackPushStrategy = choosePushStrategy(branch, false);
2167
- if (feedbackPushStrategy === "force-with-lease") {
2168
- runLog.info({ branch }, "feedback push: force-with-lease push for agent branch");
2169
- await pushBranchForce(wtPath, branch);
2170
- }
2171
- else {
2172
- await pushBranch(wtPath, branch);
2173
- }
2174
- }
2175
- runLog.info({ prUrl }, "feedback: pushed to existing PR branch");
2176
- // Re-request review via GitHub App if configured
2177
- if (rerequestReview && this.githubConfig && prNumber) {
2178
- try {
2179
- const { owner, repo } = parseRepoUrl(repoConfig.url);
2180
- const octokit = await createGitHubClient(this.githubConfig);
2181
- const reRequested = await rerequestPRReview(octokit, owner, repo, prNumber);
2182
- if (reRequested) {
2183
- runLog.info({ prUrl, prNumber }, "feedback: re-requested review");
2184
- }
2185
- else {
2186
- runLog.info({ prUrl, prNumber }, "feedback: no existing reviewers to re-request");
2187
- }
2188
- }
2189
- catch (reviewErr) {
2190
- runLog.error({ err: reviewErr }, "feedback: failed to re-request review");
2191
- }
2192
- }
2193
- });
2194
- });
2195
- await db
2196
- .update(pipelineRuns)
2197
- .set({
2198
- status: "completed",
2199
- completedAt: new Date(),
2200
- totalInputTokens: run.totalInputTokens,
2201
- totalOutputTokens: run.totalOutputTokens,
2202
- prUrl,
2203
- autoCommitted: run.autoCommitted ?? null,
2204
- })
2205
- .where(eq(pipelineRuns.id, runId));
2206
- run.status = "completed";
2207
- runLog.info({
2208
- prUrl,
2209
- totalInputTokens: run.totalInputTokens,
2210
- totalOutputTokens: run.totalOutputTokens,
2211
- autoCommitted: run.autoCommitted ?? false,
2212
- }, "feedback pipeline completed");
2213
- await this.notifier.onPipelineComplete(run, {
2214
- prUrl,
2215
- totalInputTokens: run.totalInputTokens,
2216
- totalOutputTokens: run.totalOutputTokens,
2217
- stagesCompleted: stagesToRun.length,
2218
- autoMerged: false,
2219
- });
2220
- }
2221
- catch (error) {
2222
- const errorMsg = error instanceof Error ? error.message : String(error);
2223
- runLog.error({ err: error }, "feedback pipeline failed with unexpected error");
2224
- await this.failPipeline(db, runId, run, "unknown", errorMsg, true);
2225
- }
2226
- finally {
2227
- this.budgetAlertedRuns.delete(runId);
2228
- await removeActiveWork(db, runId);
2229
- if (devcontainerSession) {
2230
- try {
2231
- await devcontainerDown(devcontainerSession);
2232
- }
2233
- catch {
2234
- // Ignore cleanup errors
2235
- }
2236
- }
2237
- // Feedback runs don't pause — always clean up worktree on completion or failure.
2238
- // Cast to string because failPipeline mutates run.status at runtime beyond the initial type.
2239
- const feedbackStatus = run.status;
2240
- if (worktreePath && (feedbackStatus === "completed" || feedbackStatus === "failed")) {
2241
- try {
2242
- await deleteWorktree(worktreePath);
2243
- }
2244
- catch {
2245
- // Ignore cleanup errors
2246
- }
2247
- }
2248
- }
2249
- }
2250
2037
  buildPipelineRun(runId, issue, pipelineKey, repoConfig, branch) {
2251
2038
  return {
2252
2039
  id: runId,