symphony-github 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 +201 -0
- package/README.md +341 -0
- package/config.example.yaml +101 -0
- package/dist/agents/launcher.d.ts +24 -0
- package/dist/agents/launcher.d.ts.map +1 -0
- package/dist/agents/launcher.js +152 -0
- package/dist/agents/launcher.js.map +1 -0
- package/dist/agents/registry.d.ts +10 -0
- package/dist/agents/registry.d.ts.map +1 -0
- package/dist/agents/registry.js +324 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/runner.d.ts +58 -0
- package/dist/agents/runner.d.ts.map +1 -0
- package/dist/agents/runner.js +1190 -0
- package/dist/agents/runner.js.map +1 -0
- package/dist/app.d.ts +11 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +829 -0
- package/dist/app.js.map +1 -0
- package/dist/components/ActivityView.d.ts +9 -0
- package/dist/components/ActivityView.d.ts.map +1 -0
- package/dist/components/ActivityView.js +73 -0
- package/dist/components/ActivityView.js.map +1 -0
- package/dist/components/Header.d.ts +12 -0
- package/dist/components/Header.d.ts.map +1 -0
- package/dist/components/Header.js +44 -0
- package/dist/components/Header.js.map +1 -0
- package/dist/components/IssueList.d.ts +10 -0
- package/dist/components/IssueList.d.ts.map +1 -0
- package/dist/components/IssueList.js +119 -0
- package/dist/components/IssueList.js.map +1 -0
- package/dist/components/Onboarding.d.ts +26 -0
- package/dist/components/Onboarding.d.ts.map +1 -0
- package/dist/components/Onboarding.js +948 -0
- package/dist/components/Onboarding.js.map +1 -0
- package/dist/components/PaneView.d.ts +9 -0
- package/dist/components/PaneView.d.ts.map +1 -0
- package/dist/components/PaneView.js +74 -0
- package/dist/components/PaneView.js.map +1 -0
- package/dist/components/StartupRecoveryView.d.ts +13 -0
- package/dist/components/StartupRecoveryView.d.ts.map +1 -0
- package/dist/components/StartupRecoveryView.js +85 -0
- package/dist/components/StartupRecoveryView.js.map +1 -0
- package/dist/components/StatusBar.d.ts +9 -0
- package/dist/components/StatusBar.d.ts.map +1 -0
- package/dist/components/StatusBar.js +70 -0
- package/dist/components/StatusBar.js.map +1 -0
- package/dist/components/TableView.d.ts +8 -0
- package/dist/components/TableView.d.ts.map +1 -0
- package/dist/components/TableView.js +87 -0
- package/dist/components/TableView.js.map +1 -0
- package/dist/config/index.d.ts +18 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +357 -0
- package/dist/config/index.js.map +1 -0
- package/dist/git/merge.d.ts +23 -0
- package/dist/git/merge.d.ts.map +1 -0
- package/dist/git/merge.js +131 -0
- package/dist/git/merge.js.map +1 -0
- package/dist/git/utils.d.ts +34 -0
- package/dist/git/utils.d.ts.map +1 -0
- package/dist/git/utils.js +214 -0
- package/dist/git/utils.js.map +1 -0
- package/dist/git/worktree.d.ts +23 -0
- package/dist/git/worktree.d.ts.map +1 -0
- package/dist/git/worktree.js +116 -0
- package/dist/git/worktree.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +225 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +59 -0
- package/dist/paths.js.map +1 -0
- package/dist/runModes.d.ts +7 -0
- package/dist/runModes.d.ts.map +1 -0
- package/dist/runModes.js +36 -0
- package/dist/runModes.js.map +1 -0
- package/dist/services/daemon.d.ts +85 -0
- package/dist/services/daemon.d.ts.map +1 -0
- package/dist/services/daemon.js +836 -0
- package/dist/services/daemon.js.map +1 -0
- package/dist/services/github.d.ts +101 -0
- package/dist/services/github.d.ts.map +1 -0
- package/dist/services/github.js +367 -0
- package/dist/services/github.js.map +1 -0
- package/dist/services/githubProgressReporter.d.ts +33 -0
- package/dist/services/githubProgressReporter.d.ts.map +1 -0
- package/dist/services/githubProgressReporter.js +272 -0
- package/dist/services/githubProgressReporter.js.map +1 -0
- package/dist/services/runtime.d.ts +43 -0
- package/dist/services/runtime.d.ts.map +1 -0
- package/dist/services/runtime.js +126 -0
- package/dist/services/runtime.js.map +1 -0
- package/dist/services/state.d.ts +43 -0
- package/dist/services/state.d.ts.map +1 -0
- package/dist/services/state.js +176 -0
- package/dist/services/state.js.map +1 -0
- package/dist/services/tmux.d.ts +50 -0
- package/dist/services/tmux.d.ts.map +1 -0
- package/dist/services/tmux.js +157 -0
- package/dist/services/tmux.js.map +1 -0
- package/dist/swarm/backlog.d.ts +25 -0
- package/dist/swarm/backlog.d.ts.map +1 -0
- package/dist/swarm/backlog.js +83 -0
- package/dist/swarm/backlog.js.map +1 -0
- package/dist/swarm/config.d.ts +14 -0
- package/dist/swarm/config.d.ts.map +1 -0
- package/dist/swarm/config.js +112 -0
- package/dist/swarm/config.js.map +1 -0
- package/dist/swarm/dependencies.d.ts +36 -0
- package/dist/swarm/dependencies.d.ts.map +1 -0
- package/dist/swarm/dependencies.js +141 -0
- package/dist/swarm/dependencies.js.map +1 -0
- package/dist/swarm/director.d.ts +67 -0
- package/dist/swarm/director.d.ts.map +1 -0
- package/dist/swarm/director.js +358 -0
- package/dist/swarm/director.js.map +1 -0
- package/dist/swarm/directorPrompt.d.ts +15 -0
- package/dist/swarm/directorPrompt.d.ts.map +1 -0
- package/dist/swarm/directorPrompt.js +60 -0
- package/dist/swarm/directorPrompt.js.map +1 -0
- package/dist/swarm/index.d.ts +7 -0
- package/dist/swarm/index.d.ts.map +1 -0
- package/dist/swarm/index.js +6 -0
- package/dist/swarm/index.js.map +1 -0
- package/dist/swarm/proposals.d.ts +29 -0
- package/dist/swarm/proposals.d.ts.map +1 -0
- package/dist/swarm/proposals.js +141 -0
- package/dist/swarm/proposals.js.map +1 -0
- package/dist/swarm/types.d.ts +65 -0
- package/dist/swarm/types.d.ts.map +1 -0
- package/dist/swarm/types.js +3 -0
- package/dist/swarm/types.js.map +1 -0
- package/dist/theme.d.ts +64 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +161 -0
- package/dist/theme.js.map +1 -0
- package/dist/triggers/index.d.ts +17 -0
- package/dist/triggers/index.d.ts.map +1 -0
- package/dist/triggers/index.js +124 -0
- package/dist/triggers/index.js.map +1 -0
- package/dist/types.d.ts +327 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/duplicateDetection.d.ts +14 -0
- package/dist/utils/duplicateDetection.d.ts.map +1 -0
- package/dist/utils/duplicateDetection.js +45 -0
- package/dist/utils/duplicateDetection.js.map +1 -0
- package/dist/utils/shell.d.ts +46 -0
- package/dist/utils/shell.d.ts.map +1 -0
- package/dist/utils/shell.js +79 -0
- package/dist/utils/shell.js.map +1 -0
- package/dist/utils/slug.d.ts +13 -0
- package/dist/utils/slug.d.ts.map +1 -0
- package/dist/utils/slug.js +32 -0
- package/dist/utils/slug.js.map +1 -0
- package/dist/version.d.ts +28 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +105 -0
- package/dist/version.js.map +1 -0
- package/examples/run-claude.example.sh +11 -0
- package/examples/run-codex.example.sh +11 -0
- package/package.json +68 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { GitHubClient } from './github.js';
|
|
3
|
+
import { StateStore } from './state.js';
|
|
4
|
+
import { RuntimeStore } from './runtime.js';
|
|
5
|
+
import { TmuxService } from './tmux.js';
|
|
6
|
+
import { GitHubProgressReporter } from './githubProgressReporter.js';
|
|
7
|
+
import { AgentRunner } from '../agents/runner.js';
|
|
8
|
+
import { resolveAgent } from '../agents/launcher.js';
|
|
9
|
+
import { evaluateTrigger } from '../triggers/index.js';
|
|
10
|
+
import { getTriggerForRepo, getModeForRepo } from '../config/index.js';
|
|
11
|
+
import { allowsAutomaticMerge } from '../runModes.js';
|
|
12
|
+
import { DirectorService } from '../swarm/director.js';
|
|
13
|
+
import { describeDuplicateMatch, findDuplicateMatch } from '../utils/duplicateDetection.js';
|
|
14
|
+
/**
|
|
15
|
+
* GitHub issue polling daemon.
|
|
16
|
+
* Ported from symphony Python's daemon.py.
|
|
17
|
+
*/
|
|
18
|
+
export class Daemon extends EventEmitter {
|
|
19
|
+
settings;
|
|
20
|
+
github;
|
|
21
|
+
state;
|
|
22
|
+
runtime;
|
|
23
|
+
tmux;
|
|
24
|
+
runner;
|
|
25
|
+
reporter;
|
|
26
|
+
director = null;
|
|
27
|
+
sessionName;
|
|
28
|
+
running = false;
|
|
29
|
+
activeRuns = new Map();
|
|
30
|
+
pollTimer = null;
|
|
31
|
+
suppressIntermediateUpdatesUntil = new Map();
|
|
32
|
+
startupCandidates = new Map();
|
|
33
|
+
startupReposScanned = new Set();
|
|
34
|
+
launchQueue = new Map();
|
|
35
|
+
constructor(settings, sessionName) {
|
|
36
|
+
super();
|
|
37
|
+
this.settings = settings;
|
|
38
|
+
this.sessionName = sessionName;
|
|
39
|
+
this.github = new GitHubClient();
|
|
40
|
+
this.state = new StateStore(settings.state_file);
|
|
41
|
+
this.runtime = new RuntimeStore(settings.runtime_root);
|
|
42
|
+
this.tmux = TmuxService.getInstance();
|
|
43
|
+
this.reporter = new GitHubProgressReporter(settings, this.github, this.runtime, this.tmux);
|
|
44
|
+
this.runner = new AgentRunner(settings, this.runtime, pane => this.handleRunnerProgress(pane));
|
|
45
|
+
if (settings.swarm?.enabled) {
|
|
46
|
+
this.director = new DirectorService(settings, this.runtime);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
getActiveRuns() {
|
|
50
|
+
return [
|
|
51
|
+
...Array.from(this.launchQueue.values()).map(candidate => this.candidateToPendingPane(candidate)),
|
|
52
|
+
...Array.from(this.activeRuns.values()),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
getRecentRuns(limit = 20) {
|
|
56
|
+
return this.runtime.listRuns(limit);
|
|
57
|
+
}
|
|
58
|
+
isRunning() {
|
|
59
|
+
return this.running;
|
|
60
|
+
}
|
|
61
|
+
async finalizeRun(runId) {
|
|
62
|
+
const pane = this.activeRuns.get(runId);
|
|
63
|
+
if (!pane)
|
|
64
|
+
return false;
|
|
65
|
+
await this.runner.requestAgentExit(pane);
|
|
66
|
+
this.setPaneStatus(pane, 'running', 'Finalizing agent session');
|
|
67
|
+
this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 5000);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
async resumeRun(runId) {
|
|
71
|
+
const pane = this.activeRuns.get(runId);
|
|
72
|
+
if (!pane)
|
|
73
|
+
return false;
|
|
74
|
+
this.setPaneStatus(pane, 'running', 'Review resumed in pane');
|
|
75
|
+
this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 10000);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
async pauseRun(runId) {
|
|
79
|
+
const pane = this.activeRuns.get(runId);
|
|
80
|
+
if (!pane?.tmux_pane_id)
|
|
81
|
+
return false;
|
|
82
|
+
await this.tmux.sendKeys(pane.tmux_pane_id, 'C-c');
|
|
83
|
+
this.setPaneStatus(pane, 'needs_attention', 'Agent interrupted by user');
|
|
84
|
+
this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 10000);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
async stopAndLabelRun(runId) {
|
|
88
|
+
const pane = this.activeRuns.get(runId);
|
|
89
|
+
if (!pane?.repo || !pane.issue_number)
|
|
90
|
+
return false;
|
|
91
|
+
const trigger = getTriggerForRepo(this.settings, pane.repo);
|
|
92
|
+
if (trigger.claim_label) {
|
|
93
|
+
try {
|
|
94
|
+
await this.github.removeLabel(pane.repo, pane.issue_number, trigger.claim_label);
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
if (trigger.ignore_labels?.length > 0) {
|
|
99
|
+
try {
|
|
100
|
+
await this.github.addLabels(pane.repo, pane.issue_number, [trigger.ignore_labels[0]]);
|
|
101
|
+
}
|
|
102
|
+
catch { /* ignore */ }
|
|
103
|
+
}
|
|
104
|
+
this.state.markHandled(pane.repo, pane.issue_number, runId);
|
|
105
|
+
if (pane.tmux_pane_id) {
|
|
106
|
+
try {
|
|
107
|
+
await this.tmux.killPane(pane.tmux_pane_id);
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
}
|
|
111
|
+
this.state.removeActiveRun(pane.repo, runId);
|
|
112
|
+
this.activeRuns.delete(runId);
|
|
113
|
+
this.state.save();
|
|
114
|
+
this.emit('run_completed', { ...pane, status: 'failed', status_detail: 'Stopped and labeled by user' });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
async prepareStartupRecovery() {
|
|
118
|
+
this.startupCandidates.clear();
|
|
119
|
+
this.startupReposScanned = new Set(this.settings.repos.map(repoConfig => repoConfig.repo));
|
|
120
|
+
const recovered = await this.restoreTrackedRuns();
|
|
121
|
+
const recoveredIssueKeys = new Set(recovered
|
|
122
|
+
.filter(pane => pane.repo && pane.issue_number)
|
|
123
|
+
.map(pane => `${pane.repo}#${pane.issue_number}`));
|
|
124
|
+
const pending = [];
|
|
125
|
+
for (const repoConfig of this.settings.repos) {
|
|
126
|
+
const repo = repoConfig.repo;
|
|
127
|
+
const trigger = getTriggerForRepo(this.settings, repo);
|
|
128
|
+
const mode = getModeForRepo(this.settings, repo);
|
|
129
|
+
if (!trigger.include_existing_open_issues) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
let issues;
|
|
133
|
+
try {
|
|
134
|
+
issues = await this.listStartupCandidateIssues(repo);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
this.emit('error', new Error(`Failed to inspect open issues for ${repo}: ${err}`));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
for (const issue of issues) {
|
|
141
|
+
const issueKey = `${repo}#${issue.number}`;
|
|
142
|
+
if (recoveredIssueKeys.has(issueKey))
|
|
143
|
+
continue;
|
|
144
|
+
if (trigger.done_label && hasLabel(issue, trigger.done_label))
|
|
145
|
+
continue;
|
|
146
|
+
let comments;
|
|
147
|
+
try {
|
|
148
|
+
comments = await this.github.listIssueComments(repo, issue.number);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
comments = [];
|
|
152
|
+
}
|
|
153
|
+
const decision = evaluateTrigger(issue, comments, trigger, {
|
|
154
|
+
isHandled: this.state.isHandled(repo, issue.number),
|
|
155
|
+
hasActiveRun: false,
|
|
156
|
+
hasOpenPr: false,
|
|
157
|
+
isClaimed: false,
|
|
158
|
+
processedCommentIds: new Set(this.state.getProcessedCommentIds(repo)),
|
|
159
|
+
});
|
|
160
|
+
const isClaimed = Boolean(trigger.claim_label && hasLabel(issue, trigger.claim_label));
|
|
161
|
+
if (!decision.should_run && !isClaimed) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const agentName = this.resolveAgentName(repo, issue, comments);
|
|
165
|
+
const reason = isClaimed && !decision.should_run
|
|
166
|
+
? `Claimed issue found without a live tmux pane (${trigger.claim_label})`
|
|
167
|
+
: decision.reason;
|
|
168
|
+
const candidate = {
|
|
169
|
+
id: issueKey,
|
|
170
|
+
repo,
|
|
171
|
+
issue,
|
|
172
|
+
comments,
|
|
173
|
+
mode,
|
|
174
|
+
reason,
|
|
175
|
+
matched_by: decision.matched_by,
|
|
176
|
+
is_claimed: isClaimed,
|
|
177
|
+
agent_name: agentName,
|
|
178
|
+
};
|
|
179
|
+
this.startupCandidates.set(candidate.id, candidate);
|
|
180
|
+
pending.push({
|
|
181
|
+
id: candidate.id,
|
|
182
|
+
repo,
|
|
183
|
+
issue_number: issue.number,
|
|
184
|
+
issue_title: issue.title,
|
|
185
|
+
issue_url: issue.html_url,
|
|
186
|
+
reason: candidate.reason,
|
|
187
|
+
matched_by: candidate.matched_by,
|
|
188
|
+
is_claimed: candidate.is_claimed,
|
|
189
|
+
agent_name: candidate.agent_name,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
pending.sort((left, right) => {
|
|
194
|
+
if (left.repo !== right.repo)
|
|
195
|
+
return left.repo.localeCompare(right.repo);
|
|
196
|
+
return left.issue_number - right.issue_number;
|
|
197
|
+
});
|
|
198
|
+
return { recovered, pending };
|
|
199
|
+
}
|
|
200
|
+
async listStartupCandidateIssues(repo) {
|
|
201
|
+
return this.github.listOpenIssues(repo);
|
|
202
|
+
}
|
|
203
|
+
async launchStartupIssues(selectedIds) {
|
|
204
|
+
for (const id of selectedIds) {
|
|
205
|
+
const candidate = this.startupCandidates.get(id);
|
|
206
|
+
if (!candidate)
|
|
207
|
+
continue;
|
|
208
|
+
this.enqueueCandidate(candidate);
|
|
209
|
+
}
|
|
210
|
+
await this.drainLaunchQueue();
|
|
211
|
+
}
|
|
212
|
+
completeStartupRecovery() {
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
for (const repo of this.startupReposScanned) {
|
|
215
|
+
this.state.setIssueCursor(repo, now);
|
|
216
|
+
this.state.setCommentCursor(repo, now);
|
|
217
|
+
}
|
|
218
|
+
this.startupCandidates.clear();
|
|
219
|
+
this.startupReposScanned.clear();
|
|
220
|
+
this.state.save();
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Start the polling loop. Launches director if swarm mode is enabled.
|
|
224
|
+
*/
|
|
225
|
+
async start() {
|
|
226
|
+
if (this.running)
|
|
227
|
+
return;
|
|
228
|
+
this.running = true;
|
|
229
|
+
this.emit('status', 'Daemon started');
|
|
230
|
+
// Launch director pane(s) if swarm mode is enabled
|
|
231
|
+
if (this.director) {
|
|
232
|
+
try {
|
|
233
|
+
const directorPanes = await this.director.start(this.sessionName);
|
|
234
|
+
for (const pane of directorPanes) {
|
|
235
|
+
this.emit('run_started', pane);
|
|
236
|
+
}
|
|
237
|
+
this.emit('status', `Swarm director launched (${directorPanes.length} pane(s))`);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
this.emit('error', new Error(`Failed to launch director: ${err}`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.poll();
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Stop the polling loop.
|
|
247
|
+
*/
|
|
248
|
+
stop() {
|
|
249
|
+
this.running = false;
|
|
250
|
+
if (this.pollTimer) {
|
|
251
|
+
clearTimeout(this.pollTimer);
|
|
252
|
+
this.pollTimer = null;
|
|
253
|
+
}
|
|
254
|
+
this.state.save();
|
|
255
|
+
this.emit('status', 'Daemon stopped');
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Run a single poll cycle, then schedule the next one.
|
|
259
|
+
*/
|
|
260
|
+
async poll() {
|
|
261
|
+
if (!this.running)
|
|
262
|
+
return;
|
|
263
|
+
try {
|
|
264
|
+
this.emit('poll_start');
|
|
265
|
+
// Check active runs for completion
|
|
266
|
+
await this.reconcileActiveRuns();
|
|
267
|
+
await this.drainLaunchQueue();
|
|
268
|
+
// Swarm mode: process proposals and dependencies
|
|
269
|
+
if (this.director) {
|
|
270
|
+
try {
|
|
271
|
+
await this.director.autoApproveProposals();
|
|
272
|
+
await this.director.processApprovedProposals();
|
|
273
|
+
await this.director.reconcileDependencies();
|
|
274
|
+
await this.director.updateDirectorContext(this.getActiveRuns());
|
|
275
|
+
if (this.director.shouldRunEvolution()) {
|
|
276
|
+
const started = await this.director.runEvolutionCycle();
|
|
277
|
+
if (started > 0) {
|
|
278
|
+
this.emit('status', `Swarm director started ${started} evolution cycle(s)`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
this.emit('error', new Error(`Swarm director error: ${err}`));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Discover and process new candidates
|
|
287
|
+
let totalDiscovered = 0;
|
|
288
|
+
let totalLaunched = 0;
|
|
289
|
+
for (const repoConfig of this.settings.repos) {
|
|
290
|
+
const repo = repoConfig.repo;
|
|
291
|
+
try {
|
|
292
|
+
const { discovered, launched } = await this.pollRepo(repo);
|
|
293
|
+
totalDiscovered += discovered;
|
|
294
|
+
totalLaunched += launched;
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
this.emit('error', new Error(`Error polling ${repo}: ${err}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
this.state.save();
|
|
301
|
+
this.emit('poll_end', { discovered: totalDiscovered, launched: totalLaunched });
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
|
305
|
+
}
|
|
306
|
+
// Schedule next poll
|
|
307
|
+
if (this.running) {
|
|
308
|
+
this.pollTimer = setTimeout(() => this.poll(), this.settings.poll_interval_sec * 1000);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Poll a single repository for new issues.
|
|
313
|
+
*/
|
|
314
|
+
async pollRepo(repo) {
|
|
315
|
+
const trigger = getTriggerForRepo(this.settings, repo);
|
|
316
|
+
const mode = getModeForRepo(this.settings, repo);
|
|
317
|
+
let discovered = 0;
|
|
318
|
+
let launched = 0;
|
|
319
|
+
let duplicateReferencesPromise = null;
|
|
320
|
+
// Get updated issues
|
|
321
|
+
const issueCursor = this.state.getIssueCursor(repo);
|
|
322
|
+
if (!issueCursor && !trigger.include_existing_open_issues) {
|
|
323
|
+
const now = new Date().toISOString();
|
|
324
|
+
this.state.setIssueCursor(repo, now);
|
|
325
|
+
this.state.setCommentCursor(repo, now);
|
|
326
|
+
return { discovered: 0, launched: 0 };
|
|
327
|
+
}
|
|
328
|
+
const issues = await this.github.listUpdatedIssues(repo, issueCursor);
|
|
329
|
+
// Update cursor to the latest issue's updated_at
|
|
330
|
+
if (issues.length > 0) {
|
|
331
|
+
const latestUpdate = issues.reduce((latest, issue) => issue.updated_at > latest ? issue.updated_at : latest, issues[0].updated_at);
|
|
332
|
+
this.state.setIssueCursor(repo, latestUpdate);
|
|
333
|
+
}
|
|
334
|
+
// Get updated comments
|
|
335
|
+
const commentCursor = this.state.getCommentCursor(repo);
|
|
336
|
+
const recentComments = await this.github.listUpdatedComments(repo, commentCursor);
|
|
337
|
+
if (recentComments.length > 0) {
|
|
338
|
+
const latestCommentUpdate = recentComments.reduce((latest, c) => c.updated_at > latest ? c.updated_at : latest, recentComments[0].updated_at);
|
|
339
|
+
this.state.setCommentCursor(repo, latestCommentUpdate);
|
|
340
|
+
}
|
|
341
|
+
// Also fetch issues mentioned in new comments
|
|
342
|
+
const commentIssueNumbers = new Set();
|
|
343
|
+
for (const comment of recentComments) {
|
|
344
|
+
if (comment.issue_url) {
|
|
345
|
+
const match = comment.issue_url.match(/\/issues\/(\d+)$/);
|
|
346
|
+
if (match) {
|
|
347
|
+
const issueNumber = parseInt(match[1], 10);
|
|
348
|
+
commentIssueNumbers.add(issueNumber);
|
|
349
|
+
if (!isSymphonyManagedComment(comment.body)) {
|
|
350
|
+
this.state.clearSuppressedIssue(repo, issueNumber);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const deferredIssueNumbers = new Set(this.state.getDeferredIssueNumbers(repo));
|
|
356
|
+
for (const deferredIssueNumber of deferredIssueNumbers) {
|
|
357
|
+
commentIssueNumbers.add(deferredIssueNumber);
|
|
358
|
+
}
|
|
359
|
+
// Merge issues from comments that aren't already in our list
|
|
360
|
+
const existingNumbers = new Set(issues.map(i => i.number));
|
|
361
|
+
for (const num of commentIssueNumbers) {
|
|
362
|
+
if (!existingNumbers.has(num)) {
|
|
363
|
+
try {
|
|
364
|
+
const issue = await this.github.getIssue(repo, num);
|
|
365
|
+
if (issue.state === 'open' && !issue.pull_request) {
|
|
366
|
+
issues.push(issue);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
this.state.clearDeferredIssue(repo, num);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
this.state.clearDeferredIssue(repo, num);
|
|
374
|
+
// Ignore - issue might be closed or deleted
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
discovered = issues.length;
|
|
379
|
+
const suppressedIssueNumbers = new Set(this.state.getSuppressedIssueNumbers(repo));
|
|
380
|
+
// Evaluate each issue
|
|
381
|
+
for (const issue of issues) {
|
|
382
|
+
// Get issue comments for trigger evaluation
|
|
383
|
+
let issueComments;
|
|
384
|
+
try {
|
|
385
|
+
issueComments = await this.github.listIssueComments(repo, issue.number);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
issueComments = [];
|
|
389
|
+
}
|
|
390
|
+
// Build context for trigger evaluation
|
|
391
|
+
const processedIds = new Set(this.state.getProcessedCommentIds(repo));
|
|
392
|
+
const context = {
|
|
393
|
+
isHandled: this.state.isHandled(repo, issue.number),
|
|
394
|
+
hasActiveRun: this.state.hasActiveRun(repo, issue.number),
|
|
395
|
+
hasOpenPr: false, // Will check below
|
|
396
|
+
isClaimed: issue.labels.some(l => l.name === trigger.claim_label),
|
|
397
|
+
processedCommentIds: processedIds,
|
|
398
|
+
};
|
|
399
|
+
// Check for open PR
|
|
400
|
+
if (trigger.skip_if_open_pr) {
|
|
401
|
+
// Simple heuristic: check if any issue label suggests a PR exists
|
|
402
|
+
// For a more thorough check, we'd search PRs, but that's expensive
|
|
403
|
+
context.hasOpenPr = false; // TODO: implement PR check if needed
|
|
404
|
+
}
|
|
405
|
+
const decision = evaluateTrigger(issue, issueComments, trigger, context);
|
|
406
|
+
if (suppressedIssueNumbers.has(issue.number)) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (!decision.should_run) {
|
|
410
|
+
this.state.clearDeferredIssue(repo, issue.number);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
const duplicateReferences = duplicateReferencesPromise ??= this.buildDuplicateReferences(repo, trigger.claim_label);
|
|
414
|
+
const duplicate = findDuplicateMatch(issue.title, (await duplicateReferences).filter(ref => ref.issueNumber !== issue.number));
|
|
415
|
+
if (duplicate) {
|
|
416
|
+
this.state.deferIssue(repo, issue.number);
|
|
417
|
+
this.emit('status', `Skipping ${repo}#${issue.number}: ${describeDuplicateMatch(duplicate)}`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
this.state.clearDeferredIssue(repo, issue.number);
|
|
421
|
+
const candidate = {
|
|
422
|
+
id: `${repo}#${issue.number}`,
|
|
423
|
+
repo,
|
|
424
|
+
issue,
|
|
425
|
+
comments: issueComments,
|
|
426
|
+
mode,
|
|
427
|
+
reason: decision.reason,
|
|
428
|
+
matched_by: decision.matched_by,
|
|
429
|
+
is_claimed: context.isClaimed,
|
|
430
|
+
agent_name: this.resolveAgentName(repo, issue, issueComments),
|
|
431
|
+
};
|
|
432
|
+
if (this.activeRuns.size >= this.settings.max_concurrent_runs) {
|
|
433
|
+
this.enqueueCandidate(candidate);
|
|
434
|
+
this.emit('status', `Queued ${repo}#${issue.number}: ${decision.reason}`);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
this.emit('status', `Triggering run for ${repo}#${issue.number}: ${decision.reason}`);
|
|
438
|
+
try {
|
|
439
|
+
await this.startIssueRun(repo, issue, issueComments, mode, trigger.claim_label);
|
|
440
|
+
if (duplicateReferencesPromise) {
|
|
441
|
+
(await duplicateReferencesPromise).push({
|
|
442
|
+
kind: 'active_run',
|
|
443
|
+
title: issue.title,
|
|
444
|
+
issueNumber: issue.number,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
this.state.clearDeferredIssue(repo, issue.number);
|
|
448
|
+
launched++;
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
this.state.deferIssue(repo, issue.number);
|
|
452
|
+
this.emit('error', new Error(`Failed to start run for ${repo}#${issue.number}: ${err}`));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { discovered, launched };
|
|
457
|
+
}
|
|
458
|
+
async restoreTrackedRuns() {
|
|
459
|
+
const recovered = [];
|
|
460
|
+
for (const { repo, record } of this.state.getAllActiveRuns()) {
|
|
461
|
+
const manifest = this.runtime.getManifest(record.run_id);
|
|
462
|
+
if (!manifest) {
|
|
463
|
+
this.state.removeActiveRun(repo, record.run_id);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const paneId = record.tmux_pane_id || manifest.tmux_pane_id || this.runtime.getRecordedPaneId(record.run_id);
|
|
467
|
+
const paneExists = Boolean(paneId && await this.tmux.paneExists(paneId));
|
|
468
|
+
if (paneExists && (record.tmux_pane_id !== paneId
|
|
469
|
+
|| record.tmux_session !== manifest.tmux_session)) {
|
|
470
|
+
this.state.addActiveRun(repo, {
|
|
471
|
+
...record,
|
|
472
|
+
tmux_session: manifest.tmux_session || record.tmux_session || this.sessionName,
|
|
473
|
+
tmux_pane_id: paneId,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
const pane = this.manifestToPane(manifest, paneExists ? paneId : undefined);
|
|
477
|
+
try {
|
|
478
|
+
const check = await this.runner.checkRunStatus(pane);
|
|
479
|
+
if (check.result) {
|
|
480
|
+
await this.completeRun(record.run_id, pane, check.result, check.detail);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
this.activeRuns.set(record.run_id, pane);
|
|
484
|
+
pane.status = check.status;
|
|
485
|
+
pane.status_detail = check.detail;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
this.emit('error', new Error(`Failed to recover ${repo}#${record.issue_number}: ${err}`));
|
|
490
|
+
}
|
|
491
|
+
recovered.push({ ...pane });
|
|
492
|
+
}
|
|
493
|
+
return recovered;
|
|
494
|
+
}
|
|
495
|
+
manifestToPane(manifest, paneId) {
|
|
496
|
+
return {
|
|
497
|
+
id: manifest.run_id,
|
|
498
|
+
slug: manifest.branch,
|
|
499
|
+
type: 'issue',
|
|
500
|
+
repo: manifest.repo,
|
|
501
|
+
issue_number: manifest.issue_number,
|
|
502
|
+
issue_title: manifest.issue_title,
|
|
503
|
+
issue_url: manifest.issue_url,
|
|
504
|
+
agent_name: manifest.agent_name,
|
|
505
|
+
agent_provider: manifest.agent_provider,
|
|
506
|
+
run_id: manifest.run_id,
|
|
507
|
+
mode: manifest.mode,
|
|
508
|
+
status: manifest.status,
|
|
509
|
+
status_detail: manifest.status_detail,
|
|
510
|
+
tmux_pane_id: paneId,
|
|
511
|
+
worktree_path: manifest.worktree_path,
|
|
512
|
+
branch: manifest.branch,
|
|
513
|
+
base_sha: manifest.base_sha,
|
|
514
|
+
started_at: manifest.started_at,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
resolveAgentName(repo, issue, comments) {
|
|
518
|
+
try {
|
|
519
|
+
return resolveAgent(issue, comments, repo, this.settings).name;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
candidateToPendingPane(candidate) {
|
|
526
|
+
return {
|
|
527
|
+
id: `queued:${candidate.id}`,
|
|
528
|
+
slug: candidate.id,
|
|
529
|
+
type: 'issue',
|
|
530
|
+
repo: candidate.repo,
|
|
531
|
+
issue_number: candidate.issue.number,
|
|
532
|
+
issue_title: candidate.issue.title,
|
|
533
|
+
issue_url: candidate.issue.html_url,
|
|
534
|
+
agent_name: candidate.agent_name,
|
|
535
|
+
run_id: undefined,
|
|
536
|
+
mode: candidate.mode,
|
|
537
|
+
status: 'pending',
|
|
538
|
+
status_detail: 'Queued: waiting for an open run slot',
|
|
539
|
+
started_at: undefined,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
enqueueCandidate(candidate) {
|
|
543
|
+
const issueKey = `${candidate.repo}#${candidate.issue.number}`;
|
|
544
|
+
if (this.launchQueue.has(issueKey) || this.state.hasActiveRun(candidate.repo, candidate.issue.number)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (Array.from(this.activeRuns.values()).some(pane => pane.repo === candidate.repo && pane.issue_number === candidate.issue.number)) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
this.launchQueue.set(issueKey, candidate);
|
|
551
|
+
this.emit('run_started', this.candidateToPendingPane(candidate));
|
|
552
|
+
}
|
|
553
|
+
async drainLaunchQueue() {
|
|
554
|
+
while (this.launchQueue.size > 0 && this.activeRuns.size < this.settings.max_concurrent_runs) {
|
|
555
|
+
const nextEntry = this.launchQueue.entries().next().value;
|
|
556
|
+
if (!nextEntry)
|
|
557
|
+
break;
|
|
558
|
+
const [issueKey, candidate] = nextEntry;
|
|
559
|
+
this.launchQueue.delete(issueKey);
|
|
560
|
+
try {
|
|
561
|
+
const trigger = getTriggerForRepo(this.settings, candidate.repo);
|
|
562
|
+
await this.startIssueRun(candidate.repo, candidate.issue, candidate.comments, candidate.mode, trigger.claim_label);
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
this.emit('error', new Error(`Failed to start recovery run for ${candidate.id}: ${err}`));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async startIssueRun(repo, issue, issueComments, mode, claimLabel) {
|
|
570
|
+
const alreadyClaimed = Boolean(claimLabel && hasLabel(issue, claimLabel));
|
|
571
|
+
let claimApplied = false;
|
|
572
|
+
if (claimLabel && !alreadyClaimed) {
|
|
573
|
+
try {
|
|
574
|
+
await this.github.addLabels(repo, issue.number, [claimLabel]);
|
|
575
|
+
claimApplied = true;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Ignore label failures; the run can still proceed.
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
const { pane, runId } = await this.runner.startRun(repo, issue, issueComments, this.sessionName, mode);
|
|
583
|
+
this.activeRuns.set(runId, pane);
|
|
584
|
+
this.state.addActiveRun(repo, {
|
|
585
|
+
run_id: runId,
|
|
586
|
+
repo,
|
|
587
|
+
issue_number: issue.number,
|
|
588
|
+
started_at: new Date().toISOString(),
|
|
589
|
+
tmux_session: this.sessionName,
|
|
590
|
+
tmux_pane_id: pane.tmux_pane_id,
|
|
591
|
+
});
|
|
592
|
+
for (const comment of issueComments) {
|
|
593
|
+
this.state.markCommentProcessed(repo, comment.id);
|
|
594
|
+
}
|
|
595
|
+
this.emit('run_started', pane);
|
|
596
|
+
await this.reporter.publishRunStarted({ ...pane });
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
if (claimApplied && claimLabel) {
|
|
600
|
+
try {
|
|
601
|
+
await this.github.removeLabel(repo, issue.number, claimLabel);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
// Ignore rollback failures.
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async buildDuplicateReferences(repo, claimLabel) {
|
|
611
|
+
const references = [];
|
|
612
|
+
for (const pane of this.activeRuns.values()) {
|
|
613
|
+
if (pane.repo !== repo || !pane.issue_title)
|
|
614
|
+
continue;
|
|
615
|
+
references.push({
|
|
616
|
+
kind: 'active_run',
|
|
617
|
+
title: pane.issue_title,
|
|
618
|
+
issueNumber: pane.issue_number,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
for (const candidate of this.launchQueue.values()) {
|
|
622
|
+
if (candidate.repo !== repo)
|
|
623
|
+
continue;
|
|
624
|
+
references.push({
|
|
625
|
+
kind: 'active_run',
|
|
626
|
+
title: candidate.issue.title,
|
|
627
|
+
issueNumber: candidate.issue.number,
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
if (claimLabel) {
|
|
631
|
+
try {
|
|
632
|
+
const claimedIssues = await this.github.listOpenIssues(repo, [claimLabel]);
|
|
633
|
+
for (const issue of claimedIssues) {
|
|
634
|
+
references.push({
|
|
635
|
+
kind: 'claimed_issue',
|
|
636
|
+
title: issue.title,
|
|
637
|
+
issueNumber: issue.number,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Ignore duplicate-check issue lookup failures
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
const openPullRequests = await this.github.listOpenPullRequests(repo);
|
|
647
|
+
for (const pr of openPullRequests) {
|
|
648
|
+
references.push({
|
|
649
|
+
kind: 'open_pr',
|
|
650
|
+
title: pr.title,
|
|
651
|
+
prNumber: pr.number,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// Ignore duplicate-check PR lookup failures
|
|
657
|
+
}
|
|
658
|
+
return references;
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Check active runs for completion.
|
|
662
|
+
*/
|
|
663
|
+
async reconcileActiveRuns() {
|
|
664
|
+
const now = Date.now();
|
|
665
|
+
for (const [runId, pane] of this.activeRuns) {
|
|
666
|
+
// Give recently started runs time for the agent to initialize
|
|
667
|
+
if (pane.started_at) {
|
|
668
|
+
const elapsed = now - new Date(pane.started_at).getTime();
|
|
669
|
+
if (elapsed < 10000)
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
const check = await this.runner.checkRunStatus(pane);
|
|
674
|
+
if (check.result) {
|
|
675
|
+
await this.completeRun(runId, pane, check.result, check.detail);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const suppressUntil = this.suppressIntermediateUpdatesUntil.get(runId) || 0;
|
|
679
|
+
const suppressIntermediateUpdate = suppressUntil > Date.now()
|
|
680
|
+
&& (check.status === 'awaiting_review' || check.status === 'needs_attention');
|
|
681
|
+
if (!suppressIntermediateUpdate) {
|
|
682
|
+
this.suppressIntermediateUpdatesUntil.delete(runId);
|
|
683
|
+
}
|
|
684
|
+
if (!suppressIntermediateUpdate && (pane.status !== check.status || pane.status_detail !== check.detail)) {
|
|
685
|
+
this.setPaneStatus(pane, check.status, check.detail);
|
|
686
|
+
}
|
|
687
|
+
else if (!suppressIntermediateUpdate && check.status === 'running') {
|
|
688
|
+
void this.reporter.publishLiveTrace({ ...pane });
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
this.emit('error', new Error(`Error checking run ${runId}: ${err}`));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
setPaneStatus(pane, status, detail) {
|
|
697
|
+
pane.status = status;
|
|
698
|
+
pane.status_detail = detail;
|
|
699
|
+
if (pane.run_id) {
|
|
700
|
+
this.runtime.updateManifest(pane.run_id, {
|
|
701
|
+
status: status === 'pending' ? 'running' : status,
|
|
702
|
+
status_detail: detail,
|
|
703
|
+
});
|
|
704
|
+
this.runtime.appendEvent(pane.run_id, {
|
|
705
|
+
type: 'status_updated',
|
|
706
|
+
status,
|
|
707
|
+
detail,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
const snapshot = { ...pane };
|
|
711
|
+
this.emit('run_updated', snapshot);
|
|
712
|
+
void this.reporter.publishRunUpdated(snapshot);
|
|
713
|
+
}
|
|
714
|
+
async completeRun(runId, pane, result, detail) {
|
|
715
|
+
const finalSnapshot = await this.capturePaneSnapshot(pane);
|
|
716
|
+
const effectiveMode = pane.mode || this.runtime.getManifest(runId)?.mode || this.settings.mode;
|
|
717
|
+
pane.status = result.success ? 'success' : 'failed';
|
|
718
|
+
pane.status_detail = detail || result.error_summary;
|
|
719
|
+
if (result.success) {
|
|
720
|
+
this.setPaneStatus(pane, 'running', allowsAutomaticMerge(effectiveMode)
|
|
721
|
+
? 'Agent finished; finalizing merge automation'
|
|
722
|
+
: 'Agent finished; finalizing PR automation');
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
await this.runner.handleRunCompletion(pane, result);
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
729
|
+
this.setPaneStatus(pane, 'needs_attention', `Run finished, but Symphony failed during finalization: ${message}`);
|
|
730
|
+
if (pane.run_id) {
|
|
731
|
+
this.runtime.updateManifest(pane.run_id, {
|
|
732
|
+
finished_at: new Date().toISOString(),
|
|
733
|
+
status: 'needs_attention',
|
|
734
|
+
status_detail: pane.status_detail,
|
|
735
|
+
exit_code: result.exit_code,
|
|
736
|
+
error_summary: pane.status_detail,
|
|
737
|
+
commits: result.commits,
|
|
738
|
+
head_sha: result.head_sha,
|
|
739
|
+
tmux_pane_id: pane.tmux_pane_id,
|
|
740
|
+
worktree_path: pane.worktree_path,
|
|
741
|
+
branch: pane.branch,
|
|
742
|
+
});
|
|
743
|
+
this.runtime.appendEvent(pane.run_id, {
|
|
744
|
+
type: 'finished',
|
|
745
|
+
status: 'needs_attention',
|
|
746
|
+
commits: result.commits,
|
|
747
|
+
detail: pane.status_detail,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const finalStatus = pane.status;
|
|
752
|
+
await this.reporter.publishRunCompleted({ ...pane }, { snapshot: finalSnapshot });
|
|
753
|
+
if (pane.repo) {
|
|
754
|
+
this.state.removeActiveRun(pane.repo, runId);
|
|
755
|
+
const shouldMarkHandled = finalStatus === 'success'
|
|
756
|
+
|| (finalStatus === 'awaiting_review' && effectiveMode !== 'auto');
|
|
757
|
+
if (shouldMarkHandled) {
|
|
758
|
+
this.state.markHandled(pane.repo, pane.issue_number, runId);
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
this.state.unmarkHandled(pane.repo, pane.issue_number);
|
|
762
|
+
}
|
|
763
|
+
const trigger = getTriggerForRepo(this.settings, pane.repo);
|
|
764
|
+
if (finalStatus === 'success') {
|
|
765
|
+
this.state.clearSuppressedIssue(pane.repo, pane.issue_number);
|
|
766
|
+
}
|
|
767
|
+
else if (effectiveMode === 'auto') {
|
|
768
|
+
this.state.suppressIssue(pane.repo, pane.issue_number);
|
|
769
|
+
}
|
|
770
|
+
if (trigger.claim_label) {
|
|
771
|
+
try {
|
|
772
|
+
await this.github.removeLabel(pane.repo, pane.issue_number, trigger.claim_label);
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
// Ignore label cleanup failures.
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
for (const obsoleteLabel of [trigger.done_label, trigger.failed_label]) {
|
|
779
|
+
if (!obsoleteLabel)
|
|
780
|
+
continue;
|
|
781
|
+
try {
|
|
782
|
+
await this.github.removeLabel(pane.repo, pane.issue_number, obsoleteLabel);
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// Ignore status-label cleanup failures.
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
const statusLabel = finalStatus === 'success'
|
|
789
|
+
? trigger.done_label
|
|
790
|
+
: (finalStatus === 'failed' ? trigger.failed_label : '');
|
|
791
|
+
if (statusLabel) {
|
|
792
|
+
try {
|
|
793
|
+
await this.github.addLabels(pane.repo, pane.issue_number, [statusLabel]);
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
// Ignore final label failures.
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
this.suppressIntermediateUpdatesUntil.delete(runId);
|
|
801
|
+
this.activeRuns.delete(runId);
|
|
802
|
+
// Notify director of completion (for dependency resolution and backlog)
|
|
803
|
+
if (this.director) {
|
|
804
|
+
this.director.recordCompletion({ ...pane });
|
|
805
|
+
}
|
|
806
|
+
this.emit('run_completed', { ...pane });
|
|
807
|
+
}
|
|
808
|
+
async handleRunnerProgress(pane) {
|
|
809
|
+
const snapshot = { ...pane };
|
|
810
|
+
this.emit('run_updated', snapshot);
|
|
811
|
+
await this.reporter.publishRunUpdated(snapshot, { force: true });
|
|
812
|
+
}
|
|
813
|
+
async capturePaneSnapshot(pane) {
|
|
814
|
+
if (!pane.tmux_pane_id)
|
|
815
|
+
return undefined;
|
|
816
|
+
if (!(await this.tmux.paneExists(pane.tmux_pane_id)))
|
|
817
|
+
return undefined;
|
|
818
|
+
const content = await this.tmux.getPaneContent(pane.tmux_pane_id, 30);
|
|
819
|
+
return content.trim() || undefined;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Run a single poll cycle (for testing / manual use).
|
|
823
|
+
*/
|
|
824
|
+
async runOnce() {
|
|
825
|
+
this.running = true;
|
|
826
|
+
await this.poll();
|
|
827
|
+
this.running = false;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function isSymphonyManagedComment(body) {
|
|
831
|
+
return typeof body === 'string' && body.includes('<!-- symphony-progress:');
|
|
832
|
+
}
|
|
833
|
+
function hasLabel(issue, label) {
|
|
834
|
+
return issue.labels.some(issueLabel => issueLabel.name === label);
|
|
835
|
+
}
|
|
836
|
+
//# sourceMappingURL=daemon.js.map
|