@vaporsoft/orc 0.1.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 (223) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +178 -0
  3. package/dist/bin/orc.d.ts +3 -0
  4. package/dist/bin/orc.d.ts.map +1 -0
  5. package/dist/bin/orc.js +4 -0
  6. package/dist/bin/orc.js.map +1 -0
  7. package/dist/src/cli.d.ts +6 -0
  8. package/dist/src/cli.d.ts.map +1 -0
  9. package/dist/src/cli.js +28 -0
  10. package/dist/src/cli.js.map +1 -0
  11. package/dist/src/commands/init.d.ts +6 -0
  12. package/dist/src/commands/init.d.ts.map +1 -0
  13. package/dist/src/commands/init.js +58 -0
  14. package/dist/src/commands/init.js.map +1 -0
  15. package/dist/src/commands/start.d.ts +17 -0
  16. package/dist/src/commands/start.d.ts.map +1 -0
  17. package/dist/src/commands/start.js +146 -0
  18. package/dist/src/commands/start.js.map +1 -0
  19. package/dist/src/constants.d.ts +18 -0
  20. package/dist/src/constants.d.ts.map +1 -0
  21. package/dist/src/constants.js +42 -0
  22. package/dist/src/constants.js.map +1 -0
  23. package/dist/src/core/comment-categorizer.d.ts +20 -0
  24. package/dist/src/core/comment-categorizer.d.ts.map +1 -0
  25. package/dist/src/core/comment-categorizer.js +208 -0
  26. package/dist/src/core/comment-categorizer.js.map +1 -0
  27. package/dist/src/core/comment-fetcher.d.ts +37 -0
  28. package/dist/src/core/comment-fetcher.d.ts.map +1 -0
  29. package/dist/src/core/comment-fetcher.js +138 -0
  30. package/dist/src/core/comment-fetcher.js.map +1 -0
  31. package/dist/src/core/daemon.d.ts +92 -0
  32. package/dist/src/core/daemon.d.ts.map +1 -0
  33. package/dist/src/core/daemon.js +896 -0
  34. package/dist/src/core/daemon.js.map +1 -0
  35. package/dist/src/core/fix-executor.d.ts +50 -0
  36. package/dist/src/core/fix-executor.d.ts.map +1 -0
  37. package/dist/src/core/fix-executor.js +374 -0
  38. package/dist/src/core/fix-executor.js.map +1 -0
  39. package/dist/src/core/git-manager.d.ts +44 -0
  40. package/dist/src/core/git-manager.d.ts.map +1 -0
  41. package/dist/src/core/git-manager.js +230 -0
  42. package/dist/src/core/git-manager.js.map +1 -0
  43. package/dist/src/core/pilot-config.d.ts +18 -0
  44. package/dist/src/core/pilot-config.d.ts.map +1 -0
  45. package/dist/src/core/pilot-config.js +76 -0
  46. package/dist/src/core/pilot-config.js.map +1 -0
  47. package/dist/src/core/progress-store.d.ts +32 -0
  48. package/dist/src/core/progress-store.d.ts.map +1 -0
  49. package/dist/src/core/progress-store.js +106 -0
  50. package/dist/src/core/progress-store.js.map +1 -0
  51. package/dist/src/core/repo-config.d.ts +11 -0
  52. package/dist/src/core/repo-config.d.ts.map +1 -0
  53. package/dist/src/core/repo-config.js +168 -0
  54. package/dist/src/core/repo-config.js.map +1 -0
  55. package/dist/src/core/session-controller.d.ts +61 -0
  56. package/dist/src/core/session-controller.d.ts.map +1 -0
  57. package/dist/src/core/session-controller.js +926 -0
  58. package/dist/src/core/session-controller.js.map +1 -0
  59. package/dist/src/core/thread-responder.d.ts +28 -0
  60. package/dist/src/core/thread-responder.d.ts.map +1 -0
  61. package/dist/src/core/thread-responder.js +193 -0
  62. package/dist/src/core/thread-responder.js.map +1 -0
  63. package/dist/src/core/worktree-manager.d.ts +26 -0
  64. package/dist/src/core/worktree-manager.d.ts.map +1 -0
  65. package/dist/src/core/worktree-manager.js +189 -0
  66. package/dist/src/core/worktree-manager.js.map +1 -0
  67. package/dist/src/github/gh-client.d.ts +57 -0
  68. package/dist/src/github/gh-client.d.ts.map +1 -0
  69. package/dist/src/github/gh-client.js +236 -0
  70. package/dist/src/github/gh-client.js.map +1 -0
  71. package/dist/src/github/queries.d.ts +9 -0
  72. package/dist/src/github/queries.d.ts.map +1 -0
  73. package/dist/src/github/queries.js +152 -0
  74. package/dist/src/github/queries.js.map +1 -0
  75. package/dist/src/github/types.d.ts +114 -0
  76. package/dist/src/github/types.d.ts.map +1 -0
  77. package/dist/src/github/types.js +3 -0
  78. package/dist/src/github/types.js.map +1 -0
  79. package/dist/src/tui/App.d.ts +8 -0
  80. package/dist/src/tui/App.d.ts.map +1 -0
  81. package/dist/src/tui/App.js +407 -0
  82. package/dist/src/tui/App.js.map +1 -0
  83. package/dist/src/tui/components/ActivityPane.d.ts +7 -0
  84. package/dist/src/tui/components/ActivityPane.d.ts.map +1 -0
  85. package/dist/src/tui/components/ActivityPane.js +10 -0
  86. package/dist/src/tui/components/ActivityPane.js.map +1 -0
  87. package/dist/src/tui/components/DetailPanel.d.ts +15 -0
  88. package/dist/src/tui/components/DetailPanel.d.ts.map +1 -0
  89. package/dist/src/tui/components/DetailPanel.js +137 -0
  90. package/dist/src/tui/components/DetailPanel.js.map +1 -0
  91. package/dist/src/tui/components/DrillInOverlay.d.ts +13 -0
  92. package/dist/src/tui/components/DrillInOverlay.d.ts.map +1 -0
  93. package/dist/src/tui/components/DrillInOverlay.js +85 -0
  94. package/dist/src/tui/components/DrillInOverlay.js.map +1 -0
  95. package/dist/src/tui/components/ExpandedContent.d.ts +10 -0
  96. package/dist/src/tui/components/ExpandedContent.d.ts.map +1 -0
  97. package/dist/src/tui/components/ExpandedContent.js +99 -0
  98. package/dist/src/tui/components/ExpandedContent.js.map +1 -0
  99. package/dist/src/tui/components/Header.d.ts +12 -0
  100. package/dist/src/tui/components/Header.d.ts.map +1 -0
  101. package/dist/src/tui/components/Header.js +35 -0
  102. package/dist/src/tui/components/Header.js.map +1 -0
  103. package/dist/src/tui/components/HelpBar.d.ts +2 -0
  104. package/dist/src/tui/components/HelpBar.d.ts.map +1 -0
  105. package/dist/src/tui/components/HelpBar.js +11 -0
  106. package/dist/src/tui/components/HelpBar.js.map +1 -0
  107. package/dist/src/tui/components/KeybindLegend.d.ts +7 -0
  108. package/dist/src/tui/components/KeybindLegend.d.ts.map +1 -0
  109. package/dist/src/tui/components/KeybindLegend.js +53 -0
  110. package/dist/src/tui/components/KeybindLegend.js.map +1 -0
  111. package/dist/src/tui/components/LogPane.d.ts +11 -0
  112. package/dist/src/tui/components/LogPane.d.ts.map +1 -0
  113. package/dist/src/tui/components/LogPane.js +31 -0
  114. package/dist/src/tui/components/LogPane.js.map +1 -0
  115. package/dist/src/tui/components/SessionList.d.ts +14 -0
  116. package/dist/src/tui/components/SessionList.d.ts.map +1 -0
  117. package/dist/src/tui/components/SessionList.js +31 -0
  118. package/dist/src/tui/components/SessionList.js.map +1 -0
  119. package/dist/src/tui/components/SessionRow.d.ts +10 -0
  120. package/dist/src/tui/components/SessionRow.d.ts.map +1 -0
  121. package/dist/src/tui/components/SessionRow.js +52 -0
  122. package/dist/src/tui/components/SessionRow.js.map +1 -0
  123. package/dist/src/tui/components/SettingsPanel.d.ts +8 -0
  124. package/dist/src/tui/components/SettingsPanel.d.ts.map +1 -0
  125. package/dist/src/tui/components/SettingsPanel.js +191 -0
  126. package/dist/src/tui/components/SettingsPanel.js.map +1 -0
  127. package/dist/src/tui/components/StatusBadge.d.ts +9 -0
  128. package/dist/src/tui/components/StatusBadge.d.ts.map +1 -0
  129. package/dist/src/tui/components/StatusBadge.js +52 -0
  130. package/dist/src/tui/components/StatusBadge.js.map +1 -0
  131. package/dist/src/tui/components/Toolbar.d.ts +5 -0
  132. package/dist/src/tui/components/Toolbar.d.ts.map +1 -0
  133. package/dist/src/tui/components/Toolbar.js +2 -0
  134. package/dist/src/tui/components/Toolbar.js.map +1 -0
  135. package/dist/src/tui/components/comment-constants.d.ts +4 -0
  136. package/dist/src/tui/components/comment-constants.d.ts.map +1 -0
  137. package/dist/src/tui/components/comment-constants.js +15 -0
  138. package/dist/src/tui/components/comment-constants.js.map +1 -0
  139. package/dist/src/tui/hooks/logFlushUtils.d.ts +15 -0
  140. package/dist/src/tui/hooks/logFlushUtils.d.ts.map +1 -0
  141. package/dist/src/tui/hooks/logFlushUtils.js +33 -0
  142. package/dist/src/tui/hooks/logFlushUtils.js.map +1 -0
  143. package/dist/src/tui/hooks/useBranchLogs.d.ts +7 -0
  144. package/dist/src/tui/hooks/useBranchLogs.d.ts.map +1 -0
  145. package/dist/src/tui/hooks/useBranchLogs.js +58 -0
  146. package/dist/src/tui/hooks/useBranchLogs.js.map +1 -0
  147. package/dist/src/tui/hooks/useDaemonState.d.ts +19 -0
  148. package/dist/src/tui/hooks/useDaemonState.d.ts.map +1 -0
  149. package/dist/src/tui/hooks/useDaemonState.js +152 -0
  150. package/dist/src/tui/hooks/useDaemonState.js.map +1 -0
  151. package/dist/src/tui/hooks/useInitialDiscovery.d.ts +8 -0
  152. package/dist/src/tui/hooks/useInitialDiscovery.d.ts.map +1 -0
  153. package/dist/src/tui/hooks/useInitialDiscovery.js +31 -0
  154. package/dist/src/tui/hooks/useInitialDiscovery.js.map +1 -0
  155. package/dist/src/tui/hooks/useLogBuffer.d.ts +7 -0
  156. package/dist/src/tui/hooks/useLogBuffer.d.ts.map +1 -0
  157. package/dist/src/tui/hooks/useLogBuffer.js +52 -0
  158. package/dist/src/tui/hooks/useLogBuffer.js.map +1 -0
  159. package/dist/src/tui/hooks/useNextCheckCountdown.d.ts +7 -0
  160. package/dist/src/tui/hooks/useNextCheckCountdown.d.ts.map +1 -0
  161. package/dist/src/tui/hooks/useNextCheckCountdown.js +42 -0
  162. package/dist/src/tui/hooks/useNextCheckCountdown.js.map +1 -0
  163. package/dist/src/tui/hooks/useTerminalFocus.d.ts +9 -0
  164. package/dist/src/tui/hooks/useTerminalFocus.d.ts.map +1 -0
  165. package/dist/src/tui/hooks/useTerminalFocus.js +43 -0
  166. package/dist/src/tui/hooks/useTerminalFocus.js.map +1 -0
  167. package/dist/src/tui/theme.d.ts +32 -0
  168. package/dist/src/tui/theme.d.ts.map +1 -0
  169. package/dist/src/tui/theme.js +61 -0
  170. package/dist/src/tui/theme.js.map +1 -0
  171. package/dist/src/types/config.d.ts +35 -0
  172. package/dist/src/types/config.d.ts.map +1 -0
  173. package/dist/src/types/config.js +14 -0
  174. package/dist/src/types/config.js.map +1 -0
  175. package/dist/src/types/index.d.ts +107 -0
  176. package/dist/src/types/index.d.ts.map +1 -0
  177. package/dist/src/types/index.js +3 -0
  178. package/dist/src/types/index.js.map +1 -0
  179. package/dist/src/utils/concurrency.d.ts +7 -0
  180. package/dist/src/utils/concurrency.d.ts.map +1 -0
  181. package/dist/src/utils/concurrency.js +26 -0
  182. package/dist/src/utils/concurrency.js.map +1 -0
  183. package/dist/src/utils/format.d.ts +2 -0
  184. package/dist/src/utils/format.d.ts.map +1 -0
  185. package/dist/src/utils/format.js +8 -0
  186. package/dist/src/utils/format.js.map +1 -0
  187. package/dist/src/utils/logger.d.ts +39 -0
  188. package/dist/src/utils/logger.d.ts.map +1 -0
  189. package/dist/src/utils/logger.js +120 -0
  190. package/dist/src/utils/logger.js.map +1 -0
  191. package/dist/src/utils/notify.d.ts +6 -0
  192. package/dist/src/utils/notify.d.ts.map +1 -0
  193. package/dist/src/utils/notify.js +15 -0
  194. package/dist/src/utils/notify.js.map +1 -0
  195. package/dist/src/utils/open-terminal.d.ts +12 -0
  196. package/dist/src/utils/open-terminal.d.ts.map +1 -0
  197. package/dist/src/utils/open-terminal.js +93 -0
  198. package/dist/src/utils/open-terminal.js.map +1 -0
  199. package/dist/src/utils/process.d.ts +14 -0
  200. package/dist/src/utils/process.d.ts.map +1 -0
  201. package/dist/src/utils/process.js +36 -0
  202. package/dist/src/utils/process.js.map +1 -0
  203. package/dist/src/utils/project-detector.d.ts +12 -0
  204. package/dist/src/utils/project-detector.d.ts.map +1 -0
  205. package/dist/src/utils/project-detector.js +123 -0
  206. package/dist/src/utils/project-detector.js.map +1 -0
  207. package/dist/src/utils/quoting.d.ts +15 -0
  208. package/dist/src/utils/quoting.d.ts.map +1 -0
  209. package/dist/src/utils/quoting.js +39 -0
  210. package/dist/src/utils/quoting.js.map +1 -0
  211. package/dist/src/utils/retry.d.ts +14 -0
  212. package/dist/src/utils/retry.d.ts.map +1 -0
  213. package/dist/src/utils/retry.js +41 -0
  214. package/dist/src/utils/retry.js.map +1 -0
  215. package/dist/src/utils/settings.d.ts +14 -0
  216. package/dist/src/utils/settings.d.ts.map +1 -0
  217. package/dist/src/utils/settings.js +21 -0
  218. package/dist/src/utils/settings.js.map +1 -0
  219. package/dist/src/utils/time.d.ts +2 -0
  220. package/dist/src/utils/time.d.ts.map +1 -0
  221. package/dist/src/utils/time.js +5 -0
  222. package/dist/src/utils/time.js.map +1 -0
  223. package/package.json +73 -0
@@ -0,0 +1,926 @@
1
+ /**
2
+ * Main orchestration loop for a single branch.
3
+ *
4
+ * Pipeline: fetch → categorize → filter → fix → verify → push → reply → re-request review.
5
+ * Emits events for the UI/TUI layer to consume.
6
+ */
7
+ import { EventEmitter } from "node:events";
8
+ import * as path from "node:path";
9
+ import { GHClient } from "../github/gh-client.js";
10
+ import { CommentFetcher } from "./comment-fetcher.js";
11
+ import { CommentCategorizer } from "./comment-categorizer.js";
12
+ import { FixExecutor } from "./fix-executor.js";
13
+ import { GitManager } from "./git-manager.js";
14
+ import { ThreadResponder } from "./thread-responder.js";
15
+ import { loadRepoConfig } from "./repo-config.js";
16
+ import { loadSettings, saveSettings } from "../utils/settings.js";
17
+ import { logger } from "../utils/logger.js";
18
+ import { exec } from "../utils/process.js";
19
+ import { RateLimitError } from "../utils/retry.js";
20
+ import { MAX_CI_FIX_ATTEMPTS } from "../constants.js";
21
+ /** Lockfiles that should be auto-resolved during rebase, not sent to Claude. */
22
+ const LOCKFILE_NAMES = new Set([
23
+ "yarn.lock",
24
+ "package-lock.json",
25
+ "pnpm-lock.yaml",
26
+ "bun.lockb",
27
+ "bun.lock",
28
+ ]);
29
+ export class SessionController extends EventEmitter {
30
+ config;
31
+ branch;
32
+ cwd;
33
+ ghClient;
34
+ fetcher;
35
+ categorizer;
36
+ executor;
37
+ gitManager;
38
+ responder;
39
+ repoConfig;
40
+ mode;
41
+ state;
42
+ progressStore;
43
+ prAuthor = null;
44
+ abortController;
45
+ running = false;
46
+ startedAt = 0;
47
+ conflictResolve = null;
48
+ pendingBaseBranch = null;
49
+ constructor(branch, config, cwd, mode = "once", progressStore) {
50
+ super();
51
+ this.branch = branch;
52
+ this.config = config;
53
+ this.cwd = cwd;
54
+ this.mode = mode;
55
+ this.progressStore = progressStore;
56
+ this.ghClient = new GHClient(cwd);
57
+ this.categorizer = new CommentCategorizer(cwd, config.confidence);
58
+ this.executor = new FixExecutor(config, cwd);
59
+ this.gitManager = new GitManager(cwd, branch);
60
+ this.abortController = new AbortController();
61
+ const lifetime = progressStore.getLifetimeStats(branch);
62
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, cycle) => sum + cycle.costUsd, 0);
63
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.inputTokens ?? 0), 0);
64
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.outputTokens ?? 0), 0);
65
+ this.state = {
66
+ branch,
67
+ prNumber: null,
68
+ prUrl: null,
69
+ status: "initializing",
70
+ mode,
71
+ commentsAddressed: 0,
72
+ totalCostUsd,
73
+ totalInputTokens,
74
+ totalOutputTokens,
75
+ error: null,
76
+ unresolvedCount: 0,
77
+ commentSummary: null,
78
+ lastPushAt: null,
79
+ claudeActivity: [],
80
+ lastSessionId: null,
81
+ workDir: cwd,
82
+ sessionExpiresAt: null,
83
+ ...lifetime,
84
+ ciStatus: "unknown",
85
+ failedChecks: [],
86
+ ciFixAttempts: 0,
87
+ conflicted: [],
88
+ hasFixupCommits: false,
89
+ };
90
+ }
91
+ updateConfig(config) {
92
+ this.config = config;
93
+ // Update dependent objects with new config
94
+ this.categorizer = new CommentCategorizer(this.cwd, config.confidence);
95
+ this.executor = new FixExecutor(config, this.cwd);
96
+ }
97
+ getState() {
98
+ return { ...this.state };
99
+ }
100
+ /** Update the conflicted paths (used by daemon to propagate merge conflict status). */
101
+ setConflicted(paths) {
102
+ this.state.conflicted = paths;
103
+ this.emit("sessionUpdate", this.branch, this.getState());
104
+ }
105
+ async start() {
106
+ this.running = true;
107
+ this.startedAt = Date.now();
108
+ if (this.mode === "watch" && this.config.sessionTimeout > 0) {
109
+ this.state.sessionExpiresAt = this.startedAt + this.config.sessionTimeout * 60 * 60 * 1000;
110
+ }
111
+ try {
112
+ this.setStatus("initializing");
113
+ await this.ghClient.validateAuth();
114
+ const pr = await this.ghClient.findPRForBranch(this.branch);
115
+ if (!pr) {
116
+ throw new Error(`No open PR found for branch "${this.branch}"`);
117
+ }
118
+ if (pr.state !== "OPEN") {
119
+ throw new Error(`PR #${pr.number} is ${pr.state}, not OPEN`);
120
+ }
121
+ this.state.prNumber = pr.number;
122
+ this.state.prUrl = pr.url;
123
+ this.prAuthor = pr.author.login;
124
+ const botLogin = await this.ghClient.getCurrentUser();
125
+ this.fetcher = new CommentFetcher(this.ghClient, pr.number, botLogin, this.branch);
126
+ this.responder = new ThreadResponder(this.ghClient, this.branch, pr.number);
127
+ this.repoConfig = await loadRepoConfig(this.cwd);
128
+ logger.info(`Starting session for PR #${pr.number} (${pr.title})`, this.branch);
129
+ if (this.mode === "once") {
130
+ await this.runCycle(pr.baseRefName);
131
+ if (this.running) {
132
+ this.setStatus("stopped");
133
+ this.running = false;
134
+ }
135
+ }
136
+ else {
137
+ while (this.running) {
138
+ await this.runCycle(pr.baseRefName);
139
+ }
140
+ }
141
+ }
142
+ catch (err) {
143
+ const message = err instanceof Error ? err.message : String(err);
144
+ this.state.error = message;
145
+ this.setStatus("error");
146
+ logger.error(message, this.branch);
147
+ }
148
+ finally {
149
+ this.emit("ready", this.branch, this.state);
150
+ try {
151
+ const safeBranch = this.branch.replace(/[^a-zA-Z0-9_-]/g, "_");
152
+ const logPath = path.join(process.cwd(), `.orc-session-${safeBranch}.txt`);
153
+ logger.dumpBranchLogs(this.branch, logPath);
154
+ }
155
+ catch { }
156
+ }
157
+ }
158
+ /** Rebase-only mode: rebase onto base, resolve conflicts if needed, push, then done. */
159
+ async startRebase() {
160
+ this.running = true;
161
+ this.startedAt = Date.now();
162
+ try {
163
+ this.setStatus("initializing");
164
+ await this.ghClient.validateAuth();
165
+ const pr = await this.ghClient.findPRForBranch(this.branch);
166
+ if (!pr) {
167
+ throw new Error(`No open PR found for branch "${this.branch}"`);
168
+ }
169
+ if (pr.state !== "OPEN") {
170
+ throw new Error(`PR #${pr.number} is ${pr.state}, not OPEN`);
171
+ }
172
+ this.state.prNumber = pr.number;
173
+ this.state.prUrl = pr.url;
174
+ this.repoConfig = await loadRepoConfig(this.cwd);
175
+ logger.info(`Rebasing PR #${pr.number} onto ${pr.baseRefName}`, this.branch);
176
+ // Rebase onto base branch
177
+ const baseBranch = pr.baseRefName;
178
+ logger.info("Rebasing onto base branch", this.branch);
179
+ const rebasedOntoBase = await this.gitManager.pullRebase(baseBranch);
180
+ if (!rebasedOntoBase) {
181
+ const settings = loadSettings();
182
+ if (settings?.autoResolveConflicts === "always" || settings?.autoResolveConflicts === true) {
183
+ logger.info("Auto-resolving merge conflicts with Claude", this.branch);
184
+ const resolved = await this.resolveConflicts(baseBranch);
185
+ if (!resolved) {
186
+ this.state.error = "Conflict resolution failed — manual intervention needed";
187
+ this.setStatus("error");
188
+ this.running = false;
189
+ return;
190
+ }
191
+ }
192
+ else if (settings?.autoResolveConflicts === "never") {
193
+ // Immediately error out without prompting
194
+ this.state.error = "Merge conflicts detected — auto-resolve disabled";
195
+ this.setStatus("error");
196
+ this.running = false;
197
+ return;
198
+ }
199
+ else {
200
+ // Pause and prompt user ("ask" or undefined)
201
+ this.pendingBaseBranch = baseBranch;
202
+ this.state.conflicted = await this.getConflictFiles(baseBranch);
203
+ if (this.state.conflicted.length === 0) {
204
+ this.state.conflicted = ["rebase conflict"];
205
+ }
206
+ this.setStatus("conflict_prompt");
207
+ const action = await new Promise((resolve) => {
208
+ this.conflictResolve = resolve;
209
+ });
210
+ this.conflictResolve = null;
211
+ this.pendingBaseBranch = null;
212
+ if (action === "dismiss") {
213
+ this.state.error = "Conflict resolution dismissed";
214
+ this.setStatus("error");
215
+ this.running = false;
216
+ return;
217
+ }
218
+ const resolved = await this.resolveConflicts(baseBranch);
219
+ if (!resolved) {
220
+ this.state.error = "Conflict resolution failed — manual intervention needed";
221
+ this.setStatus("error");
222
+ this.running = false;
223
+ return;
224
+ }
225
+ }
226
+ }
227
+ this.state.conflicted = [];
228
+ // Autosquash any fixup commits before pushing
229
+ const rebased = await this.gitManager.rebaseAutosquash(baseBranch);
230
+ if (!rebased) {
231
+ logger.warn("Autosquash rebase failed — pushing with unsquashed fixup commits", this.branch);
232
+ this.state.hasFixupCommits = true;
233
+ }
234
+ else {
235
+ this.state.hasFixupCommits = false;
236
+ }
237
+ // Push the rebased branch
238
+ this.setStatus("pushing");
239
+ const pushed = await this.gitManager.forcePushWithLease();
240
+ if (pushed) {
241
+ logger.info("Pushed rebased branch", this.branch);
242
+ this.state.lastPushAt = new Date().toISOString();
243
+ this.emit("pushed", this.branch);
244
+ }
245
+ else {
246
+ logger.error("Push failed after rebase", this.branch);
247
+ }
248
+ this.setStatus("stopped");
249
+ this.running = false;
250
+ }
251
+ catch (err) {
252
+ const message = err instanceof Error ? err.message : String(err);
253
+ this.state.error = message;
254
+ this.setStatus("error");
255
+ logger.error(message, this.branch);
256
+ }
257
+ finally {
258
+ this.emit("ready", this.branch, this.state);
259
+ try {
260
+ const safeBranch = this.branch.replace(/[^a-zA-Z0-9_-]/g, "_");
261
+ const logPath = path.join(process.cwd(), `.orc-session-${safeBranch}.txt`);
262
+ logger.dumpBranchLogs(this.branch, logPath);
263
+ }
264
+ catch { }
265
+ }
266
+ }
267
+ stop() {
268
+ this.running = false;
269
+ this.abortController.abort();
270
+ // Resolve any pending conflict resolution to avoid hanging
271
+ if (this.conflictResolve) {
272
+ this.conflictResolve("dismiss");
273
+ this.conflictResolve = null;
274
+ }
275
+ logger.info("Stopping session", this.branch);
276
+ }
277
+ /** Sleep that resolves immediately when the abort signal fires. */
278
+ sleep(ms) {
279
+ if (this.abortController.signal.aborted)
280
+ return Promise.resolve();
281
+ return new Promise((resolve) => {
282
+ const timer = setTimeout(resolve, ms);
283
+ const onAbort = () => {
284
+ clearTimeout(timer);
285
+ resolve();
286
+ };
287
+ this.abortController.signal.addEventListener("abort", onAbort, { once: true });
288
+ });
289
+ }
290
+ async runCycle(baseBranch) {
291
+ // Track cycle start cost/tokens to include CI fix costs in cycle records
292
+ const cycleStartCost = this.state.totalCostUsd;
293
+ const cycleStartInputTokens = this.state.totalInputTokens;
294
+ const cycleStartOutputTokens = this.state.totalOutputTokens;
295
+ // 0. REBASE — proactively rebase onto base branch before starting
296
+ let conflictsResolved = false;
297
+ logger.info("Rebasing onto base branch before cycle", this.branch);
298
+ const rebasedOntoBase = await this.gitManager.pullRebase(baseBranch);
299
+ if (!rebasedOntoBase) {
300
+ const settings = loadSettings();
301
+ if (settings?.autoResolveConflicts === "always" || settings?.autoResolveConflicts === true) {
302
+ logger.info("Auto-resolving merge conflicts with Claude", this.branch);
303
+ const resolved = await this.resolveConflicts(baseBranch);
304
+ if (!resolved) {
305
+ this.state.error = "Auto-resolve failed — manual intervention needed";
306
+ this.setStatus("error");
307
+ this.running = false;
308
+ return;
309
+ }
310
+ conflictsResolved = true;
311
+ }
312
+ else if (settings?.autoResolveConflicts === "never") {
313
+ // Immediately error out without prompting
314
+ this.state.error = "Merge conflicts detected — auto-resolve disabled";
315
+ this.setStatus("error");
316
+ this.running = false;
317
+ return;
318
+ }
319
+ else {
320
+ // Pause and wait for user decision via TUI prompt ("ask" or undefined)
321
+ this.pendingBaseBranch = baseBranch;
322
+ this.state.conflicted = await this.getConflictFiles(baseBranch);
323
+ if (this.state.conflicted.length === 0) {
324
+ this.state.conflicted = ["rebase conflict"];
325
+ }
326
+ this.setStatus("conflict_prompt");
327
+ const action = await new Promise((resolve) => {
328
+ this.conflictResolve = resolve;
329
+ });
330
+ this.conflictResolve = null;
331
+ this.pendingBaseBranch = null;
332
+ if (action === "dismiss") {
333
+ this.state.error = "Rebase conflict with base branch — manual intervention needed";
334
+ this.setStatus("error");
335
+ this.running = false;
336
+ return;
337
+ }
338
+ // User chose to resolve
339
+ const resolved = await this.resolveConflicts(baseBranch);
340
+ if (!resolved) {
341
+ this.state.error = "Conflict resolution failed — manual intervention needed";
342
+ this.setStatus("error");
343
+ this.running = false;
344
+ return;
345
+ }
346
+ conflictsResolved = true;
347
+ }
348
+ }
349
+ this.state.conflicted = [];
350
+ // Push the rebased branch so the resolution persists on the remote
351
+ if (conflictsResolved) {
352
+ this.setStatus("pushing");
353
+ const pushed = await this.gitManager.forcePushWithLease();
354
+ if (pushed) {
355
+ logger.info("Pushed rebased branch after conflict resolution", this.branch);
356
+ this.state.lastPushAt = new Date().toISOString();
357
+ this.emit("pushed", this.branch);
358
+ }
359
+ else {
360
+ logger.error("Failed to push after conflict resolution", this.branch);
361
+ }
362
+ }
363
+ // Reset CI fix attempts at the start of each cycle
364
+ this.state.ciFixAttempts = 0;
365
+ // 1. FETCH — poll until comments appear
366
+ this.setStatus("watching");
367
+ const fetchedComments = await this.fetcher.fetch();
368
+ if (fetchedComments.length === 0) {
369
+ this.state.unresolvedCount = 0;
370
+ if (this.mode === "once") {
371
+ logger.info("No comments to address", this.branch);
372
+ this.setStatus("stopped");
373
+ this.running = false;
374
+ return;
375
+ }
376
+ logger.info("No comments found — awaiting review feedback", this.branch);
377
+ if (!this.running)
378
+ return;
379
+ // Check if PR is still open
380
+ const pr = await this.ghClient.findPRForBranch(this.branch);
381
+ if (!pr || pr.state !== "OPEN") {
382
+ logger.info(`PR is no longer open (${pr?.state ?? "not found"})`, this.branch);
383
+ this.setStatus("stopped");
384
+ this.running = false;
385
+ return;
386
+ }
387
+ // Check session timeout even when no comments are found (0 = unlimited)
388
+ if (this.config.sessionTimeout > 0) {
389
+ const elapsedHours = (Date.now() - this.startedAt) / (1000 * 60 * 60);
390
+ if (elapsedHours >= this.config.sessionTimeout) {
391
+ logger.info(`Session timeout reached (${this.config.sessionTimeout}h)`, this.branch);
392
+ this.setStatus("stopped");
393
+ this.running = false;
394
+ return;
395
+ }
396
+ }
397
+ await this.sleep(this.config.pollInterval * 1000);
398
+ return;
399
+ }
400
+ this.state.unresolvedCount = fetchedComments.length;
401
+ // Record cycle start — registers thread IDs and opens a new cycle record
402
+ const threadIds = fetchedComments.map((c) => c.thread.threadId);
403
+ await this.progressStore.recordCycleStart(this.branch, this.state.prNumber, threadIds);
404
+ this.syncLifetimeStats();
405
+ // 2. CATEGORIZE
406
+ this.setStatus("triaging");
407
+ const { comments: categorized, costUsd: categorizationCost, inputTokens: catInputTokens, outputTokens: catOutputTokens, } = await this.categorizer.categorize(fetchedComments, this.abortController.signal);
408
+ this.state.totalCostUsd += categorizationCost;
409
+ this.state.totalInputTokens += catInputTokens;
410
+ this.state.totalOutputTokens += catOutputTokens;
411
+ if (!this.running) {
412
+ const abortCycleCost = this.state.totalCostUsd - cycleStartCost;
413
+ const abortCycleInput = this.state.totalInputTokens - cycleStartInputTokens;
414
+ const abortCycleOutput = this.state.totalOutputTokens - cycleStartOutputTokens;
415
+ await this.progressStore.recordCycleEnd(this.branch, 0, abortCycleCost, abortCycleInput, abortCycleOutput);
416
+ this.syncLifetimeStats();
417
+ return;
418
+ }
419
+ const summary = {
420
+ total: categorized.length,
421
+ mustFix: categorized.filter((c) => c.category === "must_fix").length,
422
+ shouldFix: categorized.filter((c) => c.category === "should_fix").length,
423
+ niceToHave: categorized.filter((c) => c.category === "nice_to_have").length,
424
+ falsePositive: categorized.filter((c) => c.category === "false_positive").length,
425
+ verifyAndFix: categorized.filter((c) => c.category === "verify_and_fix").length,
426
+ needsClarification: categorized.filter((c) => c.category === "needs_clarification").length,
427
+ comments: categorized,
428
+ };
429
+ this.state.commentSummary = summary;
430
+ this.emit("sessionUpdate", this.branch, this.getState());
431
+ logger.info(`Categorized: ${summary.mustFix} must_fix, ${summary.shouldFix} should_fix, ${summary.niceToHave} nice_to_have, ${summary.falsePositive} false_positive, ${summary.verifyAndFix} verify_and_fix, ${summary.needsClarification} needs_clarification`, this.branch);
432
+ // 3. FILTER by repoConfig.autoFix settings
433
+ // Separate needs_clarification — these go to the responder, not the fix executor
434
+ const clarifications = categorized.filter((c) => c.category === "needs_clarification" && this.repoConfig.autoFix.needs_clarification);
435
+ const actionable = categorized.filter((c) => {
436
+ if (c.category === "needs_clarification")
437
+ return false; // handled separately
438
+ if (c.category === "verify_and_fix")
439
+ return this.repoConfig.autoFix.verify_and_fix;
440
+ if (c.category === "false_positive")
441
+ return false;
442
+ if (c.category === "must_fix")
443
+ return this.repoConfig.autoFix.must_fix;
444
+ if (c.category === "should_fix")
445
+ return this.repoConfig.autoFix.should_fix;
446
+ if (c.category === "nice_to_have")
447
+ return this.repoConfig.autoFix.nice_to_have;
448
+ return false;
449
+ });
450
+ const skipped = categorized.filter((c) => !actionable.includes(c) && !clarifications.includes(c));
451
+ logger.info(`${actionable.length} actionable, ${clarifications.length} clarifications, ${skipped.length} skipped`, this.branch);
452
+ // Reply to skipped comments
453
+ if (skipped.length > 0 && !this.config.dryRun) {
454
+ await this.responder.replyToSkipped(skipped);
455
+ }
456
+ // Post clarification questions (one per thread, capped at 1 round)
457
+ if (clarifications.length > 0 && !this.config.dryRun) {
458
+ await this.responder.replyToClarifications(clarifications);
459
+ }
460
+ if (actionable.length === 0) {
461
+ logger.info("No actionable comments after filtering", this.branch);
462
+ const noActionCycleCost = this.state.totalCostUsd - cycleStartCost;
463
+ const noActionCycleInput = this.state.totalInputTokens - cycleStartInputTokens;
464
+ const noActionCycleOutput = this.state.totalOutputTokens - cycleStartOutputTokens;
465
+ await this.progressStore.recordCycleEnd(this.branch, 0, noActionCycleCost, noActionCycleInput, noActionCycleOutput);
466
+ this.syncLifetimeStats();
467
+ return;
468
+ }
469
+ // 4. FIX
470
+ if (this.config.dryRun) {
471
+ logger.info("[DRY RUN] Would fix the following comments:", this.branch);
472
+ for (const c of actionable) {
473
+ logger.info(` - ${c.path}:${c.line ?? "?"} (${c.category})`, this.branch);
474
+ }
475
+ const dryRunCycleCost = this.state.totalCostUsd - cycleStartCost;
476
+ const dryRunCycleInput = this.state.totalInputTokens - cycleStartInputTokens;
477
+ const dryRunCycleOutput = this.state.totalOutputTokens - cycleStartOutputTokens;
478
+ await this.progressStore.recordCycleEnd(this.branch, 0, dryRunCycleCost, dryRunCycleInput, dryRunCycleOutput);
479
+ this.syncLifetimeStats();
480
+ return;
481
+ }
482
+ this.setStatus("fixing");
483
+ this.state.claudeActivity = [];
484
+ const headBefore = await this.gitManager.getHeadSha();
485
+ const MAX_ACTIVITY_LINES = 10;
486
+ const fixResult = await this.executor.execute(actionable, this.repoConfig, this.abortController.signal, (line) => {
487
+ this.state.claudeActivity.push(line);
488
+ if (this.state.claudeActivity.length > MAX_ACTIVITY_LINES) {
489
+ this.state.claudeActivity = this.state.claudeActivity.slice(-MAX_ACTIVITY_LINES);
490
+ }
491
+ this.emit("sessionUpdate", this.branch, this.getState());
492
+ });
493
+ this.state.lastSessionId = fixResult.sessionId;
494
+ // Clean uncommitted files (e.g. .orc-verify.json) left by Claude
495
+ const postFixDirty = await this.gitManager.hasUncommittedChanges();
496
+ if (postFixDirty) {
497
+ logger.info("Cleaning uncommitted changes left by fix session", this.branch);
498
+ await this.gitManager.discardChanges();
499
+ }
500
+ // Check if Claude actually made any commits
501
+ const headAfter = await this.gitManager.getHeadSha();
502
+ const madeCommits = headAfter !== headBefore;
503
+ let pushed = false;
504
+ if (fixResult.isError) {
505
+ logger.warn("Fix session had errors, skipping push", this.branch);
506
+ }
507
+ else if (madeCommits) {
508
+ // 5. VERIFY
509
+ if (this.repoConfig.verifyCommands.length > 0) {
510
+ this.setStatus("verifying");
511
+ for (const cmd of this.repoConfig.verifyCommands) {
512
+ try {
513
+ const parts = cmd.split(/\s+/);
514
+ await exec(parts[0], parts.slice(1), { cwd: this.cwd });
515
+ logger.info(`Verify passed: ${cmd}`, this.branch);
516
+ }
517
+ catch (err) {
518
+ logger.warn(`Verify failed: ${cmd}: ${err}`, this.branch);
519
+ }
520
+ }
521
+ }
522
+ // 6. PUSH
523
+ // We already rebased onto base at the top of the cycle, so divergence
524
+ // from the remote branch is expected (rebase rewrites SHAs). Go straight
525
+ // to autosquash + force-push-with-lease.
526
+ this.setStatus("pushing");
527
+ const rebased = await this.gitManager.rebaseAutosquash(baseBranch);
528
+ if (!rebased) {
529
+ logger.warn("Autosquash rebase failed — pushing with unsquashed fixup commits", this.branch);
530
+ this.state.hasFixupCommits = true;
531
+ }
532
+ else {
533
+ this.state.hasFixupCommits = false;
534
+ }
535
+ pushed = await this.gitManager.forcePushWithLease();
536
+ if (pushed) {
537
+ this.state.lastPushAt = new Date().toISOString();
538
+ this.emit("pushed", this.branch);
539
+ }
540
+ else {
541
+ logger.error("Push failed", this.branch);
542
+ }
543
+ }
544
+ else {
545
+ logger.info("No commits made — skipping push", this.branch);
546
+ }
547
+ // 7. REPLY — only after a successful push. If nothing was pushed (error,
548
+ // push failure, no commits) skip replies so the threads stay unresolved
549
+ // and will be retried on the next cycle or handled manually.
550
+ const fixSucceeded = !fixResult.isError && madeCommits && pushed;
551
+ if (fixSucceeded) {
552
+ this.setStatus("replying");
553
+ const currentSha = await this.gitManager.getHeadSha();
554
+ const verifyComments = actionable.filter((c) => c.category === "verify_and_fix");
555
+ const regularComments = actionable.filter((c) => c.category !== "verify_and_fix");
556
+ if (regularComments.length > 0) {
557
+ await this.responder.replyToAddressed(regularComments, currentSha, fixResult.fixSummaries);
558
+ }
559
+ if (verifyComments.length > 0) {
560
+ await this.responder.replyToVerified(verifyComments, fixResult.verifyResults, currentSha);
561
+ }
562
+ }
563
+ // 8. RE-REQUEST review and CI check only after successful push
564
+ if (pushed) {
565
+ if (this.state.prNumber && madeCommits) {
566
+ const uniqueAuthors = [...new Set(actionable.map((c) => c.author))]
567
+ .filter((a) => a !== this.prAuthor);
568
+ if (uniqueAuthors.length > 0) {
569
+ await this.ghClient.requestReviewers(this.state.prNumber, uniqueAuthors);
570
+ }
571
+ }
572
+ // 8b. CI CHECK — poll checks after push and reply, auto-fix on failure
573
+ this.setStatus("watching");
574
+ await this.checkAndFixCI(baseBranch);
575
+ }
576
+ // Update running totals - only count comments as fixed when commits are made and pushed successfully
577
+ const fixedCount = (!fixResult.isError && madeCommits && pushed) ? actionable.length : 0;
578
+ if (fixedCount > 0) {
579
+ this.state.commentsAddressed += fixedCount;
580
+ }
581
+ // Calculate total cycle cost/tokens (including both review fixes and CI fixes)
582
+ this.state.totalCostUsd += fixResult.costUsd;
583
+ this.state.totalInputTokens += fixResult.inputTokens;
584
+ this.state.totalOutputTokens += fixResult.outputTokens;
585
+ const totalCycleCost = this.state.totalCostUsd - cycleStartCost;
586
+ const totalCycleInput = this.state.totalInputTokens - cycleStartInputTokens;
587
+ const totalCycleOutput = this.state.totalOutputTokens - cycleStartOutputTokens;
588
+ // Persist cycle results with total cycle cost and tokens
589
+ await this.progressStore.recordCycleEnd(this.branch, fixedCount, totalCycleCost, totalCycleInput, totalCycleOutput);
590
+ this.syncLifetimeStats();
591
+ this.state.commentSummary = null;
592
+ this.state.claudeActivity = [];
593
+ logger.info(`Cycle complete: ${fixedCount} fixed, ${skipped.length} skipped, $${totalCycleCost.toFixed(4)} cost`, this.branch);
594
+ // Check session timeout (0 = unlimited)
595
+ if (this.config.sessionTimeout > 0) {
596
+ const elapsedHours = (Date.now() - this.startedAt) / (1000 * 60 * 60);
597
+ if (elapsedHours >= this.config.sessionTimeout) {
598
+ logger.info(`Session timeout reached (${this.config.sessionTimeout}h)`, this.branch);
599
+ this.setStatus("stopped");
600
+ this.running = false;
601
+ return;
602
+ }
603
+ }
604
+ // Wait before next cycle to let GitHub propagate replies (only in watch mode)
605
+ if (this.mode === "watch") {
606
+ await this.sleep(this.config.pollInterval * 1000);
607
+ }
608
+ }
609
+ /** Called by the daemon when the TUI user presses R or A on a conflict prompt. */
610
+ acceptConflictResolution(always) {
611
+ if (always) {
612
+ saveSettings({ autoResolveConflicts: "always" });
613
+ logger.info("Saved autoResolveConflicts=always to settings", this.branch);
614
+ }
615
+ if (this.conflictResolve) {
616
+ this.conflictResolve("resolve");
617
+ }
618
+ }
619
+ /** Called by the daemon when the TUI user presses Esc on a conflict prompt. */
620
+ dismissConflictResolution() {
621
+ if (this.conflictResolve) {
622
+ this.conflictResolve("dismiss");
623
+ }
624
+ }
625
+ /** Attempt to resolve conflicts by starting the rebase, letting Claude fix conflict markers, then continuing. */
626
+ async resolveConflicts(baseBranch) {
627
+ this.setStatus("fixing");
628
+ this.state.claudeActivity = [];
629
+ // Start the rebase — this will stop at the first conflict
630
+ logger.info("Starting rebase to expose conflict markers", this.branch);
631
+ const conflictFiles = await this.gitManager.startConflictingRebase(baseBranch);
632
+ if (conflictFiles === null) {
633
+ logger.info("Rebase succeeded without conflicts", this.branch);
634
+ return true;
635
+ }
636
+ if (conflictFiles.length === 0) {
637
+ logger.error("Rebase failed for non-conflict reason", this.branch);
638
+ return false;
639
+ }
640
+ // Loop: resolve conflicts at each rebase step
641
+ const MAX_ROUNDS = 10;
642
+ let round = 0;
643
+ let currentFiles = conflictFiles;
644
+ while (round < MAX_ROUNDS) {
645
+ round++;
646
+ logger.info(`Resolving conflicts (round ${round}): ${currentFiles.join(", ")}`, this.branch);
647
+ // Auto-resolve lockfiles — never send these to Claude
648
+ const lockfiles = currentFiles.filter((f) => LOCKFILE_NAMES.has(path.basename(f)));
649
+ const codeFiles = currentFiles.filter((f) => !LOCKFILE_NAMES.has(path.basename(f)));
650
+ if (lockfiles.length > 0) {
651
+ logger.info(`Auto-resolving lockfiles: ${lockfiles.join(", ")}`, this.branch);
652
+ for (const lf of lockfiles) {
653
+ await exec("git", ["checkout", "--theirs", lf], { cwd: this.cwd });
654
+ await exec("git", ["add", lf], { cwd: this.cwd });
655
+ }
656
+ }
657
+ // If only lockfiles conflicted, skip Claude and just continue the rebase
658
+ if (codeFiles.length === 0) {
659
+ logger.info("Only lockfile conflicts — skipping Claude", this.branch);
660
+ }
661
+ else {
662
+ const conflictContext = [
663
+ "The rebase has paused with conflict markers in the following files.",
664
+ "Resolve them by editing the files to remove all <<<<<<< / ======= / >>>>>>> markers,",
665
+ "keeping the correct combined result.\n",
666
+ "Conflicting files:",
667
+ ...codeFiles.map((f) => `- ${f}`),
668
+ ].join("\n");
669
+ const MAX_ACTIVITY_LINES = 10;
670
+ const fixResult = await this.executor.executeConflictFix(conflictContext, this.repoConfig, this.abortController.signal, (line) => {
671
+ this.state.claudeActivity.push(line);
672
+ if (this.state.claudeActivity.length > MAX_ACTIVITY_LINES) {
673
+ this.state.claudeActivity = this.state.claudeActivity.slice(-MAX_ACTIVITY_LINES);
674
+ }
675
+ this.emit("sessionUpdate", this.branch, this.getState());
676
+ });
677
+ this.state.lastSessionId = fixResult.sessionId;
678
+ this.state.totalCostUsd += fixResult.costUsd;
679
+ this.state.totalInputTokens += fixResult.inputTokens;
680
+ this.state.totalOutputTokens += fixResult.outputTokens;
681
+ this.state.claudeActivity = [];
682
+ if (fixResult.isError) {
683
+ logger.error("Conflict resolution session failed", this.branch);
684
+ await this.gitManager.abortRebase();
685
+ return false;
686
+ }
687
+ }
688
+ // Stage resolved files and continue the rebase
689
+ const continued = await this.gitManager.continueRebase();
690
+ if (continued) {
691
+ logger.info("Conflicts resolved successfully", this.branch);
692
+ return true;
693
+ }
694
+ // More conflicts in the next commit — continueRebase returned false,
695
+ // check if we still have unmerged files
696
+ const { stdout } = await exec("git", ["diff", "--name-only", "--diff-filter=U"], {
697
+ cwd: this.cwd,
698
+ allowFailure: true,
699
+ });
700
+ currentFiles = stdout.trim().split("\n").filter(Boolean);
701
+ if (currentFiles.length === 0) {
702
+ logger.error("Rebase continue failed without conflict files", this.branch);
703
+ await this.gitManager.abortRebase();
704
+ return false;
705
+ }
706
+ }
707
+ logger.error(`Gave up after ${MAX_ROUNDS} conflict resolution rounds`, this.branch);
708
+ await this.gitManager.abortRebase();
709
+ return false;
710
+ }
711
+ /** Get list of files that would conflict in a merge with the base branch. */
712
+ async getConflictFiles(baseBranch) {
713
+ try {
714
+ const { stdout, exitCode } = await exec("git", [
715
+ "merge-tree", "--write-tree", "HEAD", `origin/${baseBranch}`,
716
+ ], { cwd: this.cwd, allowFailure: true });
717
+ if (exitCode === 0)
718
+ return [];
719
+ const conflictPaths = [];
720
+ for (const line of stdout.split("\n")) {
721
+ const match = line.match(/^CONFLICT \([^)]+\): Merge conflict in (.+)$/);
722
+ if (match) {
723
+ conflictPaths.push(match[1]);
724
+ }
725
+ }
726
+ return conflictPaths;
727
+ }
728
+ catch {
729
+ return [];
730
+ }
731
+ }
732
+ /** Refresh in-memory lifetime stats from the persistent store. */
733
+ syncLifetimeStats() {
734
+ const stats = this.progressStore.getLifetimeStats(this.branch);
735
+ this.state.lifetimeAddressed = stats.lifetimeAddressed;
736
+ this.state.lifetimeSeen = stats.lifetimeSeen;
737
+ this.state.cycleCount = stats.cycleCount;
738
+ this.state.cycleHistory = stats.cycleHistory;
739
+ }
740
+ /** Poll CI status after push and automatically attempt to fix failures. */
741
+ async checkAndFixCI(baseBranch) {
742
+ if (!this.state.prNumber)
743
+ return;
744
+ // Keep retrying CI fixes up to MAX_CI_FIX_ATTEMPTS per cycle
745
+ while (this.state.ciFixAttempts < MAX_CI_FIX_ATTEMPTS && this.running) {
746
+ const ciResult = await this.pollCIStatus();
747
+ this.state.ciStatus = ciResult.status;
748
+ this.state.failedChecks = ciResult.failedChecks;
749
+ this.emit("sessionUpdate", this.branch, this.getState());
750
+ // Check if session was stopped during polling
751
+ if (!this.running)
752
+ return;
753
+ // If CI is not failing, we're done
754
+ if (ciResult.status !== "failing")
755
+ return;
756
+ this.state.ciFixAttempts++;
757
+ logger.info(`CI failing, attempting auto-fix (attempt ${this.state.ciFixAttempts}/${MAX_CI_FIX_ATTEMPTS})`, this.branch);
758
+ const { context: ciContext, firstLogSnippet } = await this.buildCIContext(ciResult.failedChecks);
759
+ // Set logSnippet on the first failed check for compatibility
760
+ if (firstLogSnippet && this.state.failedChecks.length > 0 && !this.state.failedChecks[0].logSnippet) {
761
+ this.state.failedChecks[0].logSnippet = firstLogSnippet;
762
+ }
763
+ this.setStatus("fixing");
764
+ this.state.claudeActivity = [];
765
+ const headBeforeCIFix = await this.gitManager.getHeadSha();
766
+ const MAX_ACTIVITY_LINES = 10;
767
+ const ciFixResult = await this.executor.executeCIFix(ciContext, this.repoConfig, this.abortController.signal, (line) => {
768
+ this.state.claudeActivity.push(line);
769
+ if (this.state.claudeActivity.length > MAX_ACTIVITY_LINES) {
770
+ this.state.claudeActivity = this.state.claudeActivity.slice(-MAX_ACTIVITY_LINES);
771
+ }
772
+ this.emit("sessionUpdate", this.branch, this.getState());
773
+ });
774
+ this.state.lastSessionId = ciFixResult.sessionId;
775
+ this.state.totalCostUsd += ciFixResult.costUsd;
776
+ this.state.totalInputTokens += ciFixResult.inputTokens;
777
+ this.state.totalOutputTokens += ciFixResult.outputTokens;
778
+ // Clean uncommitted files left by Claude
779
+ const postCIDirty = await this.gitManager.hasUncommittedChanges();
780
+ if (postCIDirty) {
781
+ await this.gitManager.discardChanges();
782
+ }
783
+ const headAfterCIFix = await this.gitManager.getHeadSha();
784
+ let pushSucceeded = true; // Track if push succeeded
785
+ if (!ciFixResult.isError && headAfterCIFix !== headBeforeCIFix) {
786
+ this.setStatus("pushing");
787
+ const rebased = await this.gitManager.rebaseAutosquash(baseBranch);
788
+ if (!rebased) {
789
+ logger.warn("Autosquash rebase failed — pushing with unsquashed fixup commits", this.branch);
790
+ this.state.hasFixupCommits = true;
791
+ }
792
+ else {
793
+ this.state.hasFixupCommits = false;
794
+ }
795
+ const pushed = await this.gitManager.forcePushWithLease();
796
+ if (pushed) {
797
+ this.state.lastPushAt = new Date().toISOString();
798
+ this.emit("pushed", this.branch);
799
+ }
800
+ else {
801
+ pushSucceeded = false;
802
+ }
803
+ }
804
+ this.state.claudeActivity = [];
805
+ // If there was an error in the fix attempt, no changes were made, or push failed, break the loop
806
+ if (ciFixResult.isError || headAfterCIFix === headBeforeCIFix || !pushSucceeded) {
807
+ break;
808
+ }
809
+ // Reset status before continuing to next CI polling cycle
810
+ this.setStatus("watching");
811
+ }
812
+ // After exhausting all fix attempts, do a final CI poll to check if the
813
+ // last fix actually resolved CI. Without this, ciStatus stays "failing"
814
+ // from the poll at the START of the last iteration, even if the pushed
815
+ // fix made CI pass.
816
+ if (this.state.ciFixAttempts >= MAX_CI_FIX_ATTEMPTS && this.running) {
817
+ const finalResult = await this.pollCIStatus();
818
+ this.state.ciStatus = finalResult.status;
819
+ this.state.failedChecks = finalResult.failedChecks;
820
+ this.emit("sessionUpdate", this.branch, this.getState());
821
+ if (finalResult.status === "failing") {
822
+ logger.warn(`CI still failing after ${MAX_CI_FIX_ATTEMPTS} fix attempts, giving up`, this.branch);
823
+ }
824
+ }
825
+ }
826
+ /** Wait for CI checks to complete, then return aggregated status. */
827
+ async pollCIStatus() {
828
+ if (!this.state.prNumber)
829
+ return { status: "unknown", failedChecks: [] };
830
+ // Wait for checks to start (GitHub needs time after push)
831
+ this.state.ciStatus = "pending";
832
+ this.emit("sessionUpdate", this.branch, this.getState());
833
+ await this.sleep(15_000);
834
+ const maxWait = 10 * 60 * 1000; // 10 min max wait
835
+ const pollInterval = 60_000;
836
+ const start = Date.now();
837
+ while (Date.now() - start < maxWait && this.running) {
838
+ try {
839
+ const checks = await this.ghClient.getCheckRuns(this.state.prNumber);
840
+ if (checks.length === 0) {
841
+ this.state.ciStatus = "pending";
842
+ this.emit("sessionUpdate", this.branch, this.getState());
843
+ await this.sleep(pollInterval);
844
+ continue;
845
+ }
846
+ const allCompleted = checks.every((c) => c.status === "completed");
847
+ if (allCompleted) {
848
+ const passing = new Set(["success", "neutral", "skipped"]);
849
+ const failed = checks.filter((c) => !passing.has(c.conclusion ?? ""));
850
+ if (failed.length === 0) {
851
+ return { status: "passing", failedChecks: [] };
852
+ }
853
+ const failedChecks = failed.map((c) => ({
854
+ id: c.id,
855
+ name: c.name,
856
+ htmlUrl: c.html_url,
857
+ logSnippet: null,
858
+ }));
859
+ return { status: "failing", failedChecks };
860
+ }
861
+ }
862
+ catch (err) {
863
+ if (err instanceof RateLimitError) {
864
+ logger.warn("GitHub rate limit hit during CI polling, stopping", this.branch);
865
+ return { status: "unknown", failedChecks: [] };
866
+ }
867
+ logger.debug(`CI poll error: ${err}`, this.branch);
868
+ }
869
+ this.state.ciStatus = "pending";
870
+ this.emit("sessionUpdate", this.branch, this.getState());
871
+ await this.sleep(pollInterval);
872
+ }
873
+ return { status: "unknown", failedChecks: [] };
874
+ }
875
+ /** Build context string describing CI failures for the fix executor. */
876
+ async buildCIContext(failedChecks) {
877
+ if (!this.state.prNumber)
878
+ return { context: "" };
879
+ const sections = [];
880
+ sections.push(`## Failing Checks (${failedChecks.length})\n`);
881
+ // Get all failed workflow run logs for this commit
882
+ const failedRunLogs = [];
883
+ try {
884
+ const runs = await this.ghClient.getWorkflowRuns(this.state.prNumber);
885
+ for (const run of runs) {
886
+ if (run.conclusion === "failure") {
887
+ try {
888
+ const log = await this.ghClient.getFailedRunLog(run.databaseId);
889
+ if (log && log !== "(logs unavailable)") {
890
+ failedRunLogs.push({ name: run.name, log });
891
+ }
892
+ }
893
+ catch (err) {
894
+ logger.debug(`Failed to get logs for run ${run.databaseId}: ${err}`, this.branch);
895
+ }
896
+ }
897
+ }
898
+ }
899
+ catch (err) {
900
+ logger.debug(`Failed to get workflow runs: ${err}`, this.branch);
901
+ }
902
+ for (const check of failedChecks) {
903
+ sections.push(`### ${check.name}`);
904
+ sections.push(`URL: ${check.htmlUrl}\n`);
905
+ }
906
+ // Add all failed workflow logs
907
+ let firstLogSnippet;
908
+ if (failedRunLogs.length > 0) {
909
+ sections.push("## Failed Workflow Logs\n");
910
+ for (const { name, log } of failedRunLogs) {
911
+ sections.push(`### ${name}`);
912
+ sections.push("```\n" + log + "\n```\n");
913
+ // Capture the first log snippet for compatibility
914
+ if (!firstLogSnippet) {
915
+ firstLogSnippet = log.slice(0, 500);
916
+ }
917
+ }
918
+ }
919
+ return { context: sections.join("\n"), firstLogSnippet };
920
+ }
921
+ setStatus(status) {
922
+ this.state.status = status;
923
+ this.emit("statusChange", this.branch, status);
924
+ }
925
+ }
926
+ //# sourceMappingURL=session-controller.js.map