@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.
- package/LICENSE +6 -0
- package/README.md +178 -0
- package/dist/bin/orc.d.ts +3 -0
- package/dist/bin/orc.d.ts.map +1 -0
- package/dist/bin/orc.js +4 -0
- package/dist/bin/orc.js.map +1 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +28 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/commands/init.d.ts +6 -0
- package/dist/src/commands/init.d.ts.map +1 -0
- package/dist/src/commands/init.js +58 -0
- package/dist/src/commands/init.js.map +1 -0
- package/dist/src/commands/start.d.ts +17 -0
- package/dist/src/commands/start.d.ts.map +1 -0
- package/dist/src/commands/start.js +146 -0
- package/dist/src/commands/start.js.map +1 -0
- package/dist/src/constants.d.ts +18 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +42 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/core/comment-categorizer.d.ts +20 -0
- package/dist/src/core/comment-categorizer.d.ts.map +1 -0
- package/dist/src/core/comment-categorizer.js +208 -0
- package/dist/src/core/comment-categorizer.js.map +1 -0
- package/dist/src/core/comment-fetcher.d.ts +37 -0
- package/dist/src/core/comment-fetcher.d.ts.map +1 -0
- package/dist/src/core/comment-fetcher.js +138 -0
- package/dist/src/core/comment-fetcher.js.map +1 -0
- package/dist/src/core/daemon.d.ts +92 -0
- package/dist/src/core/daemon.d.ts.map +1 -0
- package/dist/src/core/daemon.js +896 -0
- package/dist/src/core/daemon.js.map +1 -0
- package/dist/src/core/fix-executor.d.ts +50 -0
- package/dist/src/core/fix-executor.d.ts.map +1 -0
- package/dist/src/core/fix-executor.js +374 -0
- package/dist/src/core/fix-executor.js.map +1 -0
- package/dist/src/core/git-manager.d.ts +44 -0
- package/dist/src/core/git-manager.d.ts.map +1 -0
- package/dist/src/core/git-manager.js +230 -0
- package/dist/src/core/git-manager.js.map +1 -0
- package/dist/src/core/pilot-config.d.ts +18 -0
- package/dist/src/core/pilot-config.d.ts.map +1 -0
- package/dist/src/core/pilot-config.js +76 -0
- package/dist/src/core/pilot-config.js.map +1 -0
- package/dist/src/core/progress-store.d.ts +32 -0
- package/dist/src/core/progress-store.d.ts.map +1 -0
- package/dist/src/core/progress-store.js +106 -0
- package/dist/src/core/progress-store.js.map +1 -0
- package/dist/src/core/repo-config.d.ts +11 -0
- package/dist/src/core/repo-config.d.ts.map +1 -0
- package/dist/src/core/repo-config.js +168 -0
- package/dist/src/core/repo-config.js.map +1 -0
- package/dist/src/core/session-controller.d.ts +61 -0
- package/dist/src/core/session-controller.d.ts.map +1 -0
- package/dist/src/core/session-controller.js +926 -0
- package/dist/src/core/session-controller.js.map +1 -0
- package/dist/src/core/thread-responder.d.ts +28 -0
- package/dist/src/core/thread-responder.d.ts.map +1 -0
- package/dist/src/core/thread-responder.js +193 -0
- package/dist/src/core/thread-responder.js.map +1 -0
- package/dist/src/core/worktree-manager.d.ts +26 -0
- package/dist/src/core/worktree-manager.d.ts.map +1 -0
- package/dist/src/core/worktree-manager.js +189 -0
- package/dist/src/core/worktree-manager.js.map +1 -0
- package/dist/src/github/gh-client.d.ts +57 -0
- package/dist/src/github/gh-client.d.ts.map +1 -0
- package/dist/src/github/gh-client.js +236 -0
- package/dist/src/github/gh-client.js.map +1 -0
- package/dist/src/github/queries.d.ts +9 -0
- package/dist/src/github/queries.d.ts.map +1 -0
- package/dist/src/github/queries.js +152 -0
- package/dist/src/github/queries.js.map +1 -0
- package/dist/src/github/types.d.ts +114 -0
- package/dist/src/github/types.d.ts.map +1 -0
- package/dist/src/github/types.js +3 -0
- package/dist/src/github/types.js.map +1 -0
- package/dist/src/tui/App.d.ts +8 -0
- package/dist/src/tui/App.d.ts.map +1 -0
- package/dist/src/tui/App.js +407 -0
- package/dist/src/tui/App.js.map +1 -0
- package/dist/src/tui/components/ActivityPane.d.ts +7 -0
- package/dist/src/tui/components/ActivityPane.d.ts.map +1 -0
- package/dist/src/tui/components/ActivityPane.js +10 -0
- package/dist/src/tui/components/ActivityPane.js.map +1 -0
- package/dist/src/tui/components/DetailPanel.d.ts +15 -0
- package/dist/src/tui/components/DetailPanel.d.ts.map +1 -0
- package/dist/src/tui/components/DetailPanel.js +137 -0
- package/dist/src/tui/components/DetailPanel.js.map +1 -0
- package/dist/src/tui/components/DrillInOverlay.d.ts +13 -0
- package/dist/src/tui/components/DrillInOverlay.d.ts.map +1 -0
- package/dist/src/tui/components/DrillInOverlay.js +85 -0
- package/dist/src/tui/components/DrillInOverlay.js.map +1 -0
- package/dist/src/tui/components/ExpandedContent.d.ts +10 -0
- package/dist/src/tui/components/ExpandedContent.d.ts.map +1 -0
- package/dist/src/tui/components/ExpandedContent.js +99 -0
- package/dist/src/tui/components/ExpandedContent.js.map +1 -0
- package/dist/src/tui/components/Header.d.ts +12 -0
- package/dist/src/tui/components/Header.d.ts.map +1 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/Header.js.map +1 -0
- package/dist/src/tui/components/HelpBar.d.ts +2 -0
- package/dist/src/tui/components/HelpBar.d.ts.map +1 -0
- package/dist/src/tui/components/HelpBar.js +11 -0
- package/dist/src/tui/components/HelpBar.js.map +1 -0
- package/dist/src/tui/components/KeybindLegend.d.ts +7 -0
- package/dist/src/tui/components/KeybindLegend.d.ts.map +1 -0
- package/dist/src/tui/components/KeybindLegend.js +53 -0
- package/dist/src/tui/components/KeybindLegend.js.map +1 -0
- package/dist/src/tui/components/LogPane.d.ts +11 -0
- package/dist/src/tui/components/LogPane.d.ts.map +1 -0
- package/dist/src/tui/components/LogPane.js +31 -0
- package/dist/src/tui/components/LogPane.js.map +1 -0
- package/dist/src/tui/components/SessionList.d.ts +14 -0
- package/dist/src/tui/components/SessionList.d.ts.map +1 -0
- package/dist/src/tui/components/SessionList.js +31 -0
- package/dist/src/tui/components/SessionList.js.map +1 -0
- package/dist/src/tui/components/SessionRow.d.ts +10 -0
- package/dist/src/tui/components/SessionRow.d.ts.map +1 -0
- package/dist/src/tui/components/SessionRow.js +52 -0
- package/dist/src/tui/components/SessionRow.js.map +1 -0
- package/dist/src/tui/components/SettingsPanel.d.ts +8 -0
- package/dist/src/tui/components/SettingsPanel.d.ts.map +1 -0
- package/dist/src/tui/components/SettingsPanel.js +191 -0
- package/dist/src/tui/components/SettingsPanel.js.map +1 -0
- package/dist/src/tui/components/StatusBadge.d.ts +9 -0
- package/dist/src/tui/components/StatusBadge.d.ts.map +1 -0
- package/dist/src/tui/components/StatusBadge.js +52 -0
- package/dist/src/tui/components/StatusBadge.js.map +1 -0
- package/dist/src/tui/components/Toolbar.d.ts +5 -0
- package/dist/src/tui/components/Toolbar.d.ts.map +1 -0
- package/dist/src/tui/components/Toolbar.js +2 -0
- package/dist/src/tui/components/Toolbar.js.map +1 -0
- package/dist/src/tui/components/comment-constants.d.ts +4 -0
- package/dist/src/tui/components/comment-constants.d.ts.map +1 -0
- package/dist/src/tui/components/comment-constants.js +15 -0
- package/dist/src/tui/components/comment-constants.js.map +1 -0
- package/dist/src/tui/hooks/logFlushUtils.d.ts +15 -0
- package/dist/src/tui/hooks/logFlushUtils.d.ts.map +1 -0
- package/dist/src/tui/hooks/logFlushUtils.js +33 -0
- package/dist/src/tui/hooks/logFlushUtils.js.map +1 -0
- package/dist/src/tui/hooks/useBranchLogs.d.ts +7 -0
- package/dist/src/tui/hooks/useBranchLogs.d.ts.map +1 -0
- package/dist/src/tui/hooks/useBranchLogs.js +58 -0
- package/dist/src/tui/hooks/useBranchLogs.js.map +1 -0
- package/dist/src/tui/hooks/useDaemonState.d.ts +19 -0
- package/dist/src/tui/hooks/useDaemonState.d.ts.map +1 -0
- package/dist/src/tui/hooks/useDaemonState.js +152 -0
- package/dist/src/tui/hooks/useDaemonState.js.map +1 -0
- package/dist/src/tui/hooks/useInitialDiscovery.d.ts +8 -0
- package/dist/src/tui/hooks/useInitialDiscovery.d.ts.map +1 -0
- package/dist/src/tui/hooks/useInitialDiscovery.js +31 -0
- package/dist/src/tui/hooks/useInitialDiscovery.js.map +1 -0
- package/dist/src/tui/hooks/useLogBuffer.d.ts +7 -0
- package/dist/src/tui/hooks/useLogBuffer.d.ts.map +1 -0
- package/dist/src/tui/hooks/useLogBuffer.js +52 -0
- package/dist/src/tui/hooks/useLogBuffer.js.map +1 -0
- package/dist/src/tui/hooks/useNextCheckCountdown.d.ts +7 -0
- package/dist/src/tui/hooks/useNextCheckCountdown.d.ts.map +1 -0
- package/dist/src/tui/hooks/useNextCheckCountdown.js +42 -0
- package/dist/src/tui/hooks/useNextCheckCountdown.js.map +1 -0
- package/dist/src/tui/hooks/useTerminalFocus.d.ts +9 -0
- package/dist/src/tui/hooks/useTerminalFocus.d.ts.map +1 -0
- package/dist/src/tui/hooks/useTerminalFocus.js +43 -0
- package/dist/src/tui/hooks/useTerminalFocus.js.map +1 -0
- package/dist/src/tui/theme.d.ts +32 -0
- package/dist/src/tui/theme.d.ts.map +1 -0
- package/dist/src/tui/theme.js +61 -0
- package/dist/src/tui/theme.js.map +1 -0
- package/dist/src/types/config.d.ts +35 -0
- package/dist/src/types/config.d.ts.map +1 -0
- package/dist/src/types/config.js +14 -0
- package/dist/src/types/config.js.map +1 -0
- package/dist/src/types/index.d.ts +107 -0
- package/dist/src/types/index.d.ts.map +1 -0
- package/dist/src/types/index.js +3 -0
- package/dist/src/types/index.js.map +1 -0
- package/dist/src/utils/concurrency.d.ts +7 -0
- package/dist/src/utils/concurrency.d.ts.map +1 -0
- package/dist/src/utils/concurrency.js +26 -0
- package/dist/src/utils/concurrency.js.map +1 -0
- package/dist/src/utils/format.d.ts +2 -0
- package/dist/src/utils/format.d.ts.map +1 -0
- package/dist/src/utils/format.js +8 -0
- package/dist/src/utils/format.js.map +1 -0
- package/dist/src/utils/logger.d.ts +39 -0
- package/dist/src/utils/logger.d.ts.map +1 -0
- package/dist/src/utils/logger.js +120 -0
- package/dist/src/utils/logger.js.map +1 -0
- package/dist/src/utils/notify.d.ts +6 -0
- package/dist/src/utils/notify.d.ts.map +1 -0
- package/dist/src/utils/notify.js +15 -0
- package/dist/src/utils/notify.js.map +1 -0
- package/dist/src/utils/open-terminal.d.ts +12 -0
- package/dist/src/utils/open-terminal.d.ts.map +1 -0
- package/dist/src/utils/open-terminal.js +93 -0
- package/dist/src/utils/open-terminal.js.map +1 -0
- package/dist/src/utils/process.d.ts +14 -0
- package/dist/src/utils/process.d.ts.map +1 -0
- package/dist/src/utils/process.js +36 -0
- package/dist/src/utils/process.js.map +1 -0
- package/dist/src/utils/project-detector.d.ts +12 -0
- package/dist/src/utils/project-detector.d.ts.map +1 -0
- package/dist/src/utils/project-detector.js +123 -0
- package/dist/src/utils/project-detector.js.map +1 -0
- package/dist/src/utils/quoting.d.ts +15 -0
- package/dist/src/utils/quoting.d.ts.map +1 -0
- package/dist/src/utils/quoting.js +39 -0
- package/dist/src/utils/quoting.js.map +1 -0
- package/dist/src/utils/retry.d.ts +14 -0
- package/dist/src/utils/retry.d.ts.map +1 -0
- package/dist/src/utils/retry.js +41 -0
- package/dist/src/utils/retry.js.map +1 -0
- package/dist/src/utils/settings.d.ts +14 -0
- package/dist/src/utils/settings.d.ts.map +1 -0
- package/dist/src/utils/settings.js +21 -0
- package/dist/src/utils/settings.js.map +1 -0
- package/dist/src/utils/time.d.ts +2 -0
- package/dist/src/utils/time.d.ts.map +1 -0
- package/dist/src/utils/time.js +5 -0
- package/dist/src/utils/time.js.map +1 -0
- 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
|