@dfsramos/foreman 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +210 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +279 -0
  59. package/public/ui.js +165 -0
package/dist/runner.js ADDED
@@ -0,0 +1,1281 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { stopOrphanedAgent } from './agent/process.js';
3
+ import { buildAgentReviewPrompt, buildPrompt, buildReviewPrompt, parseBlocked, parseCiVerdict, parseReviewVerdict, parseVerdict, } from './agent/prompt.js';
4
+ import { PRIORITY_WEIGHTS } from './projects.js';
5
+ import { AGENT_REVIEWER, CI_MAX_RERUNS, decideAgentReviewStart, decideCiFollowUp, decideRoundStart, followUp, latestOf, pickReviewer, } from './review/decide.js';
6
+ import { checkReviews, ciOf, handOverCi, progressOf, saveProgress } from './review/loop.js';
7
+ import { getSourceType } from './sources/registry.js';
8
+ /** Candidates older than this are fetched again before one is claimed: claiming a task that has moved on would pull it back. */
9
+ const CANDIDATE_FRESH_MS = 5000;
10
+ /** Pull requests are checked for feedback at most this often per project, to stay well inside GitHub's rate limit. */
11
+ const REVIEW_CHECK_MS = 120_000;
12
+ /** How long an agent's sign-in check is trusted before it is asked again, whatever the answer was. */
13
+ const SIGN_IN_RECHECK_MS = 60_000;
14
+ const PICKED_UP = 'Foreman: picked up.';
15
+ /** Copilot's reviewer login; `requestReview` turns it into the name gh asks Copilot by. */
16
+ const COPILOT_REVIEWER = 'copilot-pull-request-reviewer';
17
+ const RESULT_COMMENT_LIMIT = 2000;
18
+ const REASON_LIMIT = 500;
19
+ const USAGE_SETTING = 'agentUsage';
20
+ const WINDOW_NAMES = { five_hour: '5-hour', seven_day: '7-day' };
21
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
22
+ const truncate = (text, limit) => (text.length > limit ? `${text.slice(0, limit)}…` : text);
23
+ const configured = (agent) => agent.enabled && agent.available && agent.maxConcurrent > 0;
24
+ const windowName = (key) => WINDOW_NAMES[key] ?? key.replace(/_/g, ' ');
25
+ const allowed = (project, agent) => project.agents.length === 0 || project.agents.includes(agent.id);
26
+ /** A reset time in the machine's local time, with the date only when it is not today. */
27
+ export function formatReset(iso, now) {
28
+ const date = new Date(iso);
29
+ const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
30
+ if (date.toDateString() === now.toDateString())
31
+ return time;
32
+ return `${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${time}`;
33
+ }
34
+ /** Change in utilization per window between two reports; null where the window reset in between. */
35
+ export function usageDelta(start, end, shared) {
36
+ const windows = {};
37
+ for (const [name, after] of Object.entries(end.windows)) {
38
+ const before = start.windows[name];
39
+ windows[name] = before && before.resetsAt === after.resetsAt ? Math.max(0, after.utilization - before.utilization) : null;
40
+ }
41
+ return { windows, shared };
42
+ }
43
+ /**
44
+ * Polls each started project for work and hands free agent slots to the projects that have some.
45
+ * Several runs can be in progress at once, up to each agent's concurrency limit. When projects
46
+ * compete for a slot they take weighted turns by priority, so a busy project cannot starve another.
47
+ */
48
+ export class Runner extends EventEmitter {
49
+ deps;
50
+ now;
51
+ setTimer;
52
+ clearTimer;
53
+ projects = new Map();
54
+ active = new Map();
55
+ /** Tasks being claimed, before their run exists. */
56
+ claiming = new Map();
57
+ /** Tail of the queue of git operations per repository; git's own locks do not tolerate overlap. */
58
+ repoLocks = new Map();
59
+ /** Latest account usage reported by each agent's CLI; kept across restarts. */
60
+ usage = new Map();
61
+ /** The latest sign-in check per agent id, and when it was made. */
62
+ signIns = new Map();
63
+ filling = null;
64
+ fillAgain = false;
65
+ served = 0;
66
+ constructor(deps) {
67
+ super();
68
+ this.deps = deps;
69
+ this.now = deps.now ?? (() => new Date());
70
+ this.setTimer = deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
71
+ this.clearTimer = deps.clearTimer ?? ((handle) => clearTimeout(handle));
72
+ try {
73
+ const stored = deps.db.getSetting(USAGE_SETTING);
74
+ for (const [agentId, usage] of Object.entries(stored ?? {}))
75
+ this.usage.set(agentId, usage);
76
+ }
77
+ catch {
78
+ // Usage is informational; a bad stored value is simply dropped.
79
+ }
80
+ }
81
+ /* ---------- status ---------- */
82
+ getStatus() {
83
+ const projects = this.deps.getProjects().map((project) => this.projectStatus(project.id));
84
+ const anyStarted = projects.some((project) => project.state === 'idle' || project.state === 'working');
85
+ const busy = this.active.size > 0 || this.claiming.size > 0 || [...this.projects.values()].some((state) => state.releasing !== null);
86
+ return {
87
+ state: anyStarted ? (busy ? 'working' : 'idle') : busy ? 'stopping' : 'stopped',
88
+ projects,
89
+ activeRunIds: [...this.active.keys()],
90
+ };
91
+ }
92
+ projectStatus(projectId) {
93
+ const state = this.projects.get(projectId);
94
+ const activeRunIds = [...this.active.entries()].filter(([, entry]) => entry.projectId === projectId).map(([id]) => id);
95
+ const busy = activeRunIds.length > 0 || [...this.claiming.values()].includes(projectId) || (state !== undefined && state.releasing !== null);
96
+ return {
97
+ id: projectId,
98
+ state: state?.started ? (busy ? 'working' : 'idle') : busy ? 'stopping' : 'stopped',
99
+ lastPollAt: state?.lastPollAt ?? null,
100
+ nextPollAt: state?.nextPollAt ?? null,
101
+ lastError: state?.lastError ?? state?.reviewError ?? null,
102
+ waiting: (state?.candidates.length ?? 0) + (state?.reviewWork.length ?? 0) > 0,
103
+ activeRunIds,
104
+ inReview: state?.inReview ?? 0,
105
+ awaitingApproval: state?.awaitingApproval ?? 0,
106
+ };
107
+ }
108
+ /** True when the agent's latest sign-in check, still within its recheck window, found it signed out. */
109
+ signedOut(agentId) {
110
+ const check = this.signIns.get(agentId);
111
+ return check !== undefined && !check.signedIn && this.now().getTime() - check.at < SIGN_IN_RECHECK_MS;
112
+ }
113
+ /** Latest account usage per agent id, for agents whose CLI reports it. */
114
+ getUsage() {
115
+ return Object.fromEntries(this.usage);
116
+ }
117
+ /**
118
+ * Why an agent may not take new work because of the account's plan usage, or null when it may.
119
+ * A window whose reset time has passed no longer counts, so a pause lifts by itself.
120
+ */
121
+ pauseReason(agent) {
122
+ const usage = this.usage.get(agent.id);
123
+ if (!usage)
124
+ return null;
125
+ const now = this.now().getTime();
126
+ const current = Object.entries(usage.windows).filter(([, window]) => !window.resetsAt || new Date(window.resetsAt).getTime() > now);
127
+ // Every window has reset since the report: nothing in it is known to hold any more.
128
+ if (current.length === 0)
129
+ return null;
130
+ if (agent.maxUsagePercent !== null) {
131
+ for (const [name, window] of current) {
132
+ const percent = Math.round(window.utilization * 100);
133
+ if (window.utilization * 100 >= agent.maxUsagePercent) {
134
+ const until = window.resetsAt ? `, resets ${formatReset(window.resetsAt, this.now())}` : '';
135
+ return `${windowName(name)} plan usage is at ${percent}% (limit ${agent.maxUsagePercent}%${until})`;
136
+ }
137
+ }
138
+ }
139
+ if (usage.usingOverage && !agent.allowOverage)
140
+ return 'the account is spending paid overage';
141
+ return null;
142
+ }
143
+ /* ---------- control ---------- */
144
+ /** Begins polling a project. Returns false unless that project exists and is fully stopped. */
145
+ startProject(projectId) {
146
+ if (!this.deps.getProjects().some((project) => project.id === projectId))
147
+ return false;
148
+ if (this.projectStatus(projectId).state !== 'stopped')
149
+ return false;
150
+ const state = this.stateOf(projectId);
151
+ state.started = true;
152
+ state.lastError = null;
153
+ this.emitStatus();
154
+ this.launchPoll(projectId);
155
+ return true;
156
+ }
157
+ /** Stops polling a project; its runs in progress are allowed to finish. Returns false when it is not started. */
158
+ stopProject(projectId) {
159
+ const state = this.projects.get(projectId);
160
+ if (!state?.started)
161
+ return false;
162
+ state.started = false;
163
+ state.generation += 1;
164
+ state.candidates = [];
165
+ state.reviewWork = [];
166
+ this.cancelTimer(state);
167
+ this.releaseIfIdle(projectId);
168
+ this.emitStatus();
169
+ return true;
170
+ }
171
+ /** Starts every stopped project. Returns how many were started. */
172
+ startAll() {
173
+ return this.deps.getProjects().filter((project) => this.startProject(project.id)).length;
174
+ }
175
+ /** Stops every started project. Returns how many were stopped. */
176
+ stopAll() {
177
+ return [...this.projects.keys()].filter((projectId) => this.stopProject(projectId)).length;
178
+ }
179
+ /** Forgets a project that no longer exists. Returns false while it is started or has runs in progress. */
180
+ forgetProject(projectId) {
181
+ if (this.projectStatus(projectId).state !== 'stopped')
182
+ return false;
183
+ this.projects.delete(projectId);
184
+ return true;
185
+ }
186
+ /**
187
+ * Has the source hand the lease back once a stopped project's runs and claims are over, after any
188
+ * poll or refresh still in flight. The project reads `stopping` until the hand-back has landed, so a restart
189
+ * waits for it; a hand-back that fails is reported and the project is stopped all the same.
190
+ */
191
+ releaseIfIdle(projectId) {
192
+ const state = this.projects.get(projectId);
193
+ const source = state?.source;
194
+ if (!state || state.started || state.releasing !== null || !source?.release)
195
+ return;
196
+ if ([...this.active.values()].some((entry) => entry.projectId === projectId) || [...this.claiming.values()].includes(projectId))
197
+ return;
198
+ state.releasing = (async () => {
199
+ // A poll in flight when the project stopped, or a fill pass refreshing its candidates, may be taking the
200
+ // lease; the hand-back comes after them.
201
+ await state.polling;
202
+ await this.filling;
203
+ if (state.started)
204
+ return;
205
+ await source.release();
206
+ })()
207
+ .catch((error) => {
208
+ state.lastError = `Could not hand back the lease: ${errorMessage(error)}`;
209
+ })
210
+ .finally(() => {
211
+ state.releasing = null;
212
+ this.emitStatus();
213
+ });
214
+ this.emitStatus();
215
+ }
216
+ /** Kills the agent of one run. Returns false when that run is not active or has nothing left to stop. */
217
+ abort(runId) {
218
+ const entry = this.active.get(runId);
219
+ if (!entry || !entry.abortable || entry.controller.signal.aborted)
220
+ return false;
221
+ entry.controller.abort();
222
+ return true;
223
+ }
224
+ /** Aborts every run and every claim in progress. Used on shutdown. */
225
+ abortAll() {
226
+ let count = 0;
227
+ for (const controller of this.claiming.keys())
228
+ controller.abort();
229
+ for (const runId of this.active.keys()) {
230
+ if (this.abort(runId))
231
+ count += 1;
232
+ }
233
+ return count;
234
+ }
235
+ /** Resolves once the polls in flight, the slot allocation, every active run and every hand-back of a lease have finished. */
236
+ async settled() {
237
+ const pending = () => [
238
+ this.filling,
239
+ ...[...this.projects.values()].map((state) => state.polling),
240
+ ...[...this.projects.values()].map((state) => state.releasing),
241
+ ...[...this.active.values()].map((entry) => entry.done),
242
+ ];
243
+ while (pending().some((promise) => promise !== null)) {
244
+ await Promise.all(pending());
245
+ }
246
+ }
247
+ /** Marks runs left "running" by a previous process as failed, stops their agents and tells the tracker. */
248
+ async recoverInterrupted() {
249
+ const stopAgent = this.deps.stopOrphanedAgent ?? stopOrphanedAgent;
250
+ for (const stale of this.deps.db.listRunning()) {
251
+ const reason = 'Foreman restarted while this run was in progress';
252
+ const run = this.deps.db.finishRun(stale.id, 'failed', reason);
253
+ this.emit('run', run);
254
+ try {
255
+ const bin = this.deps.getAgents().find((agent) => agent.id === stale.agent)?.bin;
256
+ if (stale.agentPid !== null && bin && stopAgent(stale.agentPid, bin)) {
257
+ this.log(run.id, 'foreman', `Stopped the agent left running by the previous foreman process (pid ${stale.agentPid}).`);
258
+ }
259
+ const project = this.deps.getProjects().find((candidate) => candidate.id === stale.projectId);
260
+ if (project) {
261
+ const source = this.deps.createSource(project);
262
+ const task = { id: run.issueId };
263
+ await this.commentQuietly(run, source, task, this.failureComment(run, `Foreman: run failed.\n\n${reason}.`, source));
264
+ }
265
+ else {
266
+ this.log(run.id, 'foreman', 'The project this run belonged to no longer exists, so its tracker was not told.');
267
+ }
268
+ }
269
+ catch (error) {
270
+ this.log(run.id, 'foreman', `Could not finish recovering ${run.identifier}: ${errorMessage(error)}`);
271
+ }
272
+ }
273
+ }
274
+ /* ---------- polling ---------- */
275
+ stateOf(projectId) {
276
+ let state = this.projects.get(projectId);
277
+ if (!state) {
278
+ state = {
279
+ started: false,
280
+ generation: 0,
281
+ timer: null,
282
+ polling: null,
283
+ releasing: null,
284
+ lastPollAt: null,
285
+ nextPollAt: null,
286
+ lastError: null,
287
+ candidates: [],
288
+ source: null,
289
+ fetchedAt: 0,
290
+ reviewWork: [],
291
+ reviewCheckedAt: 0,
292
+ reviewError: null,
293
+ inReview: 0,
294
+ awaitingApproval: 0,
295
+ credit: 0,
296
+ lastServed: 0,
297
+ };
298
+ this.projects.set(projectId, state);
299
+ }
300
+ return state;
301
+ }
302
+ launchPoll(projectId) {
303
+ const state = this.stateOf(projectId);
304
+ const generation = state.generation;
305
+ const live = () => state.started && state.generation === generation;
306
+ const poll = this.poll(projectId, state, live)
307
+ .catch((error) => {
308
+ // poll() handles its own failures; this guards against a bug taking the loop down.
309
+ if (live())
310
+ state.lastError = `Unexpected error: ${errorMessage(error)}`;
311
+ })
312
+ .finally(() => {
313
+ if (state.polling === poll)
314
+ state.polling = null;
315
+ if (live())
316
+ this.schedule(projectId, state);
317
+ // A project stopped during its first poll has its source only now.
318
+ this.releaseIfIdle(projectId);
319
+ this.requestFill();
320
+ });
321
+ state.polling = poll;
322
+ }
323
+ /** Asks one project's tracker what is waiting. Claims nothing; that is `fill()`'s job. */
324
+ async poll(projectId, state, live) {
325
+ state.lastPollAt = this.now().toISOString();
326
+ state.nextPollAt = null;
327
+ this.emitStatus();
328
+ const project = this.deps.getProjects().find((candidate) => candidate.id === projectId);
329
+ if (!project) {
330
+ // Deleted while started.
331
+ state.started = false;
332
+ return;
333
+ }
334
+ const blocked = this.noAgentReason(project);
335
+ if (blocked) {
336
+ state.lastError = blocked;
337
+ state.candidates = [];
338
+ return;
339
+ }
340
+ try {
341
+ await this.refresh(project, state);
342
+ if (!live()) {
343
+ state.candidates = [];
344
+ return;
345
+ }
346
+ state.lastError = null;
347
+ }
348
+ catch (error) {
349
+ if (!live())
350
+ return;
351
+ state.lastError = errorMessage(error);
352
+ state.candidates = [];
353
+ return;
354
+ }
355
+ if (project.reviewMaxRounds > 0 && this.now().getTime() - state.reviewCheckedAt >= REVIEW_CHECK_MS) {
356
+ try {
357
+ const problems = await this.checkReviews(project, state);
358
+ if (live())
359
+ state.reviewError = problems;
360
+ }
361
+ catch (error) {
362
+ // New work is unaffected; only the watching of pull requests is.
363
+ if (live())
364
+ state.reviewError = `Review check failed: ${errorMessage(error)}`;
365
+ state.reviewWork = [];
366
+ }
367
+ if (!live())
368
+ state.reviewWork = [];
369
+ }
370
+ }
371
+ /**
372
+ * Looks at the pull request of every issue in review or awaiting approval: finishes the merged
373
+ * ones, moves plainly approved ones on, and queues the rest of the new feedback for an agent.
374
+ * Returns what went wrong with single issues, which were left as they were, or null.
375
+ */
376
+ async checkReviews(project, state) {
377
+ const source = state.source ?? this.deps.createSource(project);
378
+ state.reviewCheckedAt = this.now().getTime();
379
+ const busy = new Set([...this.active.values()].map((entry) => entry.taskId));
380
+ const { db, git } = this.deps;
381
+ const check = await checkReviews({ db, git, now: this.now }, project, source, busy);
382
+ state.inReview = check.inReview;
383
+ state.awaitingApproval = check.awaitingApproval;
384
+ state.source = source;
385
+ state.reviewWork = check.work;
386
+ return check.errors.length > 0 ? check.errors.join('; ') : null;
387
+ }
388
+ async refresh(project, state) {
389
+ const source = this.deps.createSource(project);
390
+ const candidates = await source.fetchCandidates();
391
+ // A tracker may still list a task for a moment after it was claimed.
392
+ const taken = new Set([...this.active.values()].map((entry) => entry.taskId));
393
+ state.source = source;
394
+ state.candidates = candidates.filter((task) => !taken.has(task.id));
395
+ state.fetchedAt = this.now().getTime();
396
+ }
397
+ /** Why no agent can work on this project at all, or null when at least one could. */
398
+ noAgentReason(project) {
399
+ const agents = this.deps.getAgents().filter((agent) => allowed(project, agent));
400
+ if (agents.some((agent) => this.usable(agent)))
401
+ return null;
402
+ const reasons = agents.filter(configured).flatMap((agent) => {
403
+ if (this.signedOut(agent.id))
404
+ return [`${agent.adapter.name} is not signed in (checked again within a minute)`];
405
+ const paused = this.pauseReason(agent);
406
+ return paused ? [`${agent.adapter.name} is paused: ${paused}`] : [];
407
+ });
408
+ if (reasons.length > 0)
409
+ return `No agent can take work: ${reasons.join('; ')}`;
410
+ return project.agents.length > 0
411
+ ? 'No agent can take work: none of the agents allowed for this project is enabled and installed'
412
+ : 'No agent can take work: enable at least one installed agent with a concurrency above zero';
413
+ }
414
+ schedule(projectId, state) {
415
+ const project = this.deps.getProjects().find((candidate) => candidate.id === projectId);
416
+ if (!project)
417
+ return;
418
+ const ms = project.pollIntervalSeconds * 1000;
419
+ this.cancelTimer(state);
420
+ state.nextPollAt = new Date(this.now().getTime() + ms).toISOString();
421
+ state.timer = this.setTimer(() => {
422
+ state.timer = null;
423
+ if (state.started)
424
+ this.launchPoll(projectId);
425
+ }, ms);
426
+ this.emitStatus();
427
+ }
428
+ cancelTimer(state) {
429
+ if (state.timer !== null) {
430
+ this.clearTimer(state.timer);
431
+ state.timer = null;
432
+ }
433
+ state.nextPollAt = null;
434
+ }
435
+ /* ---------- slot allocation ---------- */
436
+ usable(agent) {
437
+ return configured(agent) && this.pauseReason(agent) === null && !this.signedOut(agent.id);
438
+ }
439
+ /**
440
+ * Asks whether the agent is signed in, at most once per recheck window. A signed-out agent takes
441
+ * no work until a later check finds it signed in; an answer that cannot be read lets it work.
442
+ */
443
+ async confirmSignedIn(project, agent) {
444
+ if (!this.deps.checkSignIn)
445
+ return true;
446
+ const cached = this.signIns.get(agent.id);
447
+ if (cached && this.now().getTime() - cached.at < SIGN_IN_RECHECK_MS)
448
+ return cached.signedIn;
449
+ const check = await this.deps.checkSignIn(agent);
450
+ const signedIn = check.signedIn !== false;
451
+ this.signIns.set(agent.id, { signedIn, at: this.now().getTime() });
452
+ if (!signedIn) {
453
+ this.stateOf(project.id).lastError = this.noAgentReason(project);
454
+ this.emitStatus();
455
+ }
456
+ return signedIn;
457
+ }
458
+ /** The first agent in preference order that this project may use and that has a slot free. */
459
+ freeAgent(project, agents) {
460
+ return this.freeAgents(project, agents)[0];
461
+ }
462
+ /** Every agent this project may use that has a slot free, in preference order. */
463
+ freeAgents(project, agents) {
464
+ const counts = new Map();
465
+ for (const entry of this.active.values())
466
+ counts.set(entry.agentId, (counts.get(entry.agentId) ?? 0) + 1);
467
+ return agents.filter((agent) => allowed(project, agent) && this.usable(agent) && (counts.get(agent.id) ?? 0) < agent.maxConcurrent);
468
+ }
469
+ /** Runs `fill()` now, or once more after the pass already in progress. */
470
+ requestFill() {
471
+ if (this.filling) {
472
+ this.fillAgain = true;
473
+ return;
474
+ }
475
+ const pass = this.fill()
476
+ .catch(() => {
477
+ // fill() reports its failures on the project concerned.
478
+ })
479
+ .finally(() => {
480
+ this.filling = null;
481
+ if (this.fillAgain) {
482
+ this.fillAgain = false;
483
+ this.requestFill();
484
+ }
485
+ });
486
+ this.filling = pass;
487
+ }
488
+ /** Hands free agent slots to projects with work waiting, one task at a time, until either runs out. */
489
+ async fill() {
490
+ for (;;) {
491
+ const agents = this.deps.getAgents();
492
+ const projects = this.deps.getProjects();
493
+ const eligible = projects.filter((project) => {
494
+ const state = this.projects.get(project.id);
495
+ return state?.started && state.candidates.length + state.reviewWork.length > 0 && this.freeAgent(project, agents) !== undefined;
496
+ });
497
+ if (eligible.length === 0)
498
+ return;
499
+ const project = this.takeTurn(eligible);
500
+ const state = this.stateOf(project.id);
501
+ // Work already under review comes before new work: finish what was started.
502
+ if (state.reviewWork[0]?.agentReview) {
503
+ const item = state.reviewWork[0];
504
+ const pick = pickReviewer(project.reviewMode, this.deps.db.authorAgent(item.task.id), this.freeAgents(project, agents));
505
+ // No suitable reviewer free now: the next review check offers it again.
506
+ if (!pick) {
507
+ state.reviewWork.shift();
508
+ continue;
509
+ }
510
+ if (!(await this.confirmSignedIn(project, pick.agent)))
511
+ continue;
512
+ state.reviewWork.shift();
513
+ if (state.source)
514
+ await this.startAgentReview(project, state.source, item, pick.agent, pick.self);
515
+ continue;
516
+ }
517
+ if (state.reviewWork.length > 0) {
518
+ const reviewer = this.freeAgent(project, agents);
519
+ if (!reviewer || !(await this.confirmSignedIn(project, reviewer)))
520
+ continue;
521
+ const review = state.reviewWork.shift();
522
+ if (review && state.source)
523
+ await this.startReview(project, state.source, review, reviewer);
524
+ continue;
525
+ }
526
+ if (this.now().getTime() - state.fetchedAt > CANDIDATE_FRESH_MS) {
527
+ try {
528
+ await this.refresh(project, state);
529
+ }
530
+ catch (error) {
531
+ state.lastError = errorMessage(error);
532
+ state.candidates = [];
533
+ this.emitStatus();
534
+ }
535
+ // The project may have been stopped, or its work taken elsewhere, while the tracker answered.
536
+ if (!state.started || state.candidates.length === 0)
537
+ continue;
538
+ }
539
+ const agent = this.freeAgent(project, agents);
540
+ if (!agent || !(await this.confirmSignedIn(project, agent)))
541
+ continue;
542
+ const task = state.candidates.shift();
543
+ const source = state.source;
544
+ if (!task || !source)
545
+ continue;
546
+ const claimed = await this.claim(project, source, task, agent);
547
+ // A failed claim usually means the tracker is unhappy; the rest can wait for the next poll.
548
+ if (!claimed)
549
+ state.candidates = [];
550
+ }
551
+ }
552
+ /**
553
+ * Smooth weighted round-robin: every contender earns its weight, the richest goes next and pays
554
+ * the sum of all weights. Over time each project is served in proportion to its weight.
555
+ */
556
+ takeTurn(eligible) {
557
+ let total = 0;
558
+ for (const project of eligible) {
559
+ const weight = PRIORITY_WEIGHTS[project.priority];
560
+ this.stateOf(project.id).credit += weight;
561
+ total += weight;
562
+ }
563
+ const chosen = eligible.reduce((best, project) => {
564
+ const a = this.stateOf(best.id);
565
+ const b = this.stateOf(project.id);
566
+ return b.credit > a.credit || (b.credit === a.credit && b.lastServed < a.lastServed) ? project : best;
567
+ });
568
+ const state = this.stateOf(chosen.id);
569
+ state.credit -= total;
570
+ state.lastServed = ++this.served;
571
+ return chosen;
572
+ }
573
+ /** Claims a task and starts its run without waiting for it. Returns false when the claim failed. */
574
+ async claim(project, source, task, agent) {
575
+ const state = this.stateOf(project.id);
576
+ // Created before the claim so that a shutdown can stop a run that is still being claimed.
577
+ const controller = new AbortController();
578
+ this.claiming.set(controller, project.id);
579
+ this.emitStatus();
580
+ let run;
581
+ try {
582
+ await source.claim(task);
583
+ run = this.deps.db.createRun(task, agent.id, project);
584
+ }
585
+ catch (error) {
586
+ // Not claimed, or claimed but unrecordable: either way no run exists.
587
+ state.lastError = `Could not claim ${task.identifier}: ${errorMessage(error)}`;
588
+ this.claiming.delete(controller);
589
+ this.releaseIfIdle(project.id);
590
+ this.emitStatus();
591
+ return false;
592
+ }
593
+ this.begin(run, task, project, source, agent, controller);
594
+ return true;
595
+ }
596
+ /**
597
+ * Hands a round of review feedback to an agent, or, once the round limit is reached, hands the
598
+ * issue over to a person. Returns after the run has started, without waiting for it.
599
+ */
600
+ async startReview(project, source, item, agent) {
601
+ const state = this.stateOf(project.id);
602
+ const { task } = item;
603
+ const controller = new AbortController();
604
+ this.claiming.set(controller, project.id);
605
+ this.emitStatus();
606
+ try {
607
+ // The feedback was found some time ago; look again before acting on it.
608
+ const pr = await this.deps.git.pullRequest(project.repoPath, task.branchName);
609
+ const progress = await progressOf(this.deps.db, source, task);
610
+ const ci = await ciOf(this.deps.git, project, pr);
611
+ const decision = decideRoundStart({ pr, progress, ignoreAuthors: project.reviewIgnoreAuthors, now: this.now(), maxRounds: project.reviewMaxRounds, ci });
612
+ if (decision.kind === 'none' || !pr)
613
+ return;
614
+ if (decision.kind === 'hand_over') {
615
+ await this.handOver(source, item, pr, progress, decision);
616
+ return;
617
+ }
618
+ const { round, feedback, conflict, ciFailures } = decision;
619
+ await source.claim(task);
620
+ const run = this.deps.db.createRun(task, agent.id, project, { round, branch: task.branchName });
621
+ this.begin(run, task, project, source, agent, controller, {
622
+ item: { ...item, pr, feedback, conflict, ...(ciFailures ? { ciFailures } : { ciFailures: undefined }) },
623
+ round,
624
+ });
625
+ }
626
+ catch (error) {
627
+ state.reviewError = `Could not start the review of ${task.identifier}: ${errorMessage(error)}`;
628
+ }
629
+ finally {
630
+ this.claiming.delete(controller);
631
+ this.releaseIfIdle(project.id);
632
+ this.emitStatus();
633
+ }
634
+ }
635
+ /**
636
+ * Has an agent review the pull request, or, once the round limit is reached, hands the issue over
637
+ * to a person. The issue stays in review while the reviewer reads. Returns once the run has started.
638
+ */
639
+ async startAgentReview(project, source, item, agent, self) {
640
+ const state = this.stateOf(project.id);
641
+ const { task } = item;
642
+ const controller = new AbortController();
643
+ this.claiming.set(controller, project.id);
644
+ this.emitStatus();
645
+ try {
646
+ const pr = await this.deps.git.pullRequest(project.repoPath, task.branchName);
647
+ const progress = await progressOf(this.deps.db, source, task);
648
+ const decision = decideAgentReviewStart({ pr, progress, maxRounds: project.reviewMaxRounds, now: this.now() });
649
+ if (decision.kind === 'none' || !pr)
650
+ return;
651
+ if (decision.kind === 'hand_over') {
652
+ await this.handOver(source, item, pr, progress, decision);
653
+ return;
654
+ }
655
+ const run = this.deps.db.createRun(task, agent.id, project, { round: decision.round, branch: task.branchName, kind: 'agent_review' });
656
+ this.begin(run, task, project, source, agent, controller, { item: { ...item, pr }, round: decision.round, agentReview: { self } });
657
+ }
658
+ catch (error) {
659
+ state.reviewError = `Could not start the agent review of ${task.identifier}: ${errorMessage(error)}`;
660
+ }
661
+ finally {
662
+ this.claiming.delete(controller);
663
+ this.releaseIfIdle(project.id);
664
+ this.emitStatus();
665
+ }
666
+ }
667
+ /** At the round limit: tells the issue a person is needed, moves it to approval if it is in review, and stops raising it. */
668
+ async handOver(source, item, pr, progress, decision) {
669
+ const { approval } = source.describe();
670
+ await source.comment(item.task, `Foreman: ${decision.rounds} rounds of follow-up have been handled and the pull request needs attention again (${decision.needs}). Handing over to a person${item.stage === 'review' ? `; moved to "${approval}"` : ''}: ${pr.url}`);
671
+ if (item.stage === 'review')
672
+ await source.approve(item.task);
673
+ await saveProgress(this.deps.db, source, { ...progress, handledUntil: decision.handledUntil, handedOver: true });
674
+ }
675
+ /** Registers a claimed run as active and starts working on it. */
676
+ begin(run, task, project, source, agent, controller, review) {
677
+ const entry = {
678
+ controller,
679
+ agentId: agent.id,
680
+ projectId: project.id,
681
+ taskId: task.id,
682
+ abortable: true,
683
+ done: Promise.resolve(),
684
+ usageStart: null,
685
+ usageEnd: null,
686
+ shared: false,
687
+ };
688
+ // Usage is reported for the whole account, so overlapping runs on one agent cannot be told apart.
689
+ for (const other of this.active.values()) {
690
+ if (other.agentId === agent.id) {
691
+ other.shared = true;
692
+ entry.shared = true;
693
+ }
694
+ }
695
+ this.active.set(run.id, entry);
696
+ this.claiming.delete(controller);
697
+ this.emit('run', run);
698
+ this.emitStatus();
699
+ entry.done = this.work(run, task, project, source, agent, entry, review).finally(() => {
700
+ this.active.delete(run.id);
701
+ this.releaseIfIdle(entry.projectId);
702
+ this.emitStatus();
703
+ // A slot is free again.
704
+ this.requestFill();
705
+ });
706
+ }
707
+ /* ---------- one run ---------- */
708
+ /** Runs one claimed task through the rest of the pipeline. Never throws. */
709
+ async work(run, task, project, source, agent, entry, review) {
710
+ try {
711
+ this.log(run.id, 'foreman', review?.agentReview
712
+ ? `Reviewing ${task.identifier}; it stays in "${source.describe().done}".`
713
+ : `Claimed ${task.identifier}: moved to "${source.describe().claimed}".`);
714
+ const doing = review?.agentReview
715
+ ? `reviewing the pull request${review.agentReview.self ? ' (self-review)' : ''}`
716
+ : review
717
+ ? followUp(review.item)
718
+ : null;
719
+ await this.commentQuietly(run, source, task, review
720
+ ? `${PICKED_UP} ${agent.adapter.name} is ${doing}, round ${review.round} of ${project.reviewMaxRounds}.`
721
+ : `${PICKED_UP} ${agent.adapter.name} is working on it.`);
722
+ const outcome = await this.execute(run.id, task, project, agent, entry, review);
723
+ entry.abortable = false;
724
+ // A run that did not succeed keeps the step it stopped at, so the UI can show where it failed.
725
+ if (outcome.kind === 'succeeded' || outcome.kind === 'reviewed' || outcome.kind === 'agent_reviewed')
726
+ this.update(run.id, { step: 'report' });
727
+ await this.report(run.id, task, project, source, outcome, review);
728
+ }
729
+ catch (error) {
730
+ const reason = `Unexpected error: ${errorMessage(error)}`;
731
+ try {
732
+ const failed = this.deps.db.finishRun(run.id, 'failed', reason);
733
+ this.emit('run', failed);
734
+ await this.commentQuietly(failed, source, task, this.failureComment(failed, `Foreman: run failed.\n\n${reason}.`, source));
735
+ }
736
+ catch (inner) {
737
+ this.stateOf(project.id).lastError = `${reason}; additionally: ${errorMessage(inner)}`;
738
+ }
739
+ }
740
+ }
741
+ async execute(runId, task, project, agent, entry, review) {
742
+ const { git } = this.deps;
743
+ const { signal } = entry.controller;
744
+ const branch = task.branchName;
745
+ // An abort can arrive while the task is still being claimed.
746
+ if (signal.aborted)
747
+ return { kind: 'aborted' };
748
+ this.update(runId, { step: 'worktree', branch });
749
+ let worktreePath;
750
+ let baseRef;
751
+ try {
752
+ [baseRef, worktreePath] = await this.withRepoLock(project.repoPath, async () => {
753
+ await git.fetch(project.repoPath, signal);
754
+ if (review) {
755
+ // The branch already exists on origin: work continues where the pull request stands.
756
+ const existing = await git.checkoutWorktree({ repoPath: project.repoPath, worktreeRoot: project.worktreeRoot, identifier: task.identifier, branch, signal });
757
+ return [`origin/${branch}`, existing];
758
+ }
759
+ const base = await git.defaultBaseRef(project.repoPath, signal);
760
+ const created = await git.createWorktree({
761
+ repoPath: project.repoPath,
762
+ worktreeRoot: project.worktreeRoot,
763
+ identifier: task.identifier,
764
+ branch,
765
+ baseRef: base,
766
+ signal,
767
+ });
768
+ return [base, created];
769
+ });
770
+ }
771
+ catch (error) {
772
+ if (signal.aborted)
773
+ return { kind: 'aborted' };
774
+ return { kind: 'failed', reason: `Worktree setup failed: ${errorMessage(error)}` };
775
+ }
776
+ this.update(runId, { worktreePath });
777
+ this.log(runId, 'foreman', `Created worktree ${worktreePath} on branch ${branch} from ${baseRef}.`);
778
+ if (signal.aborted)
779
+ return { kind: 'aborted' };
780
+ this.update(runId, { step: 'agent' });
781
+ this.log(runId, 'foreman', `Starting ${agent.adapter.name}.`);
782
+ const tracker = getSourceType(project.sourceType)?.name ?? project.sourceType;
783
+ // The git source keeps its tasks in the repository the agent works in; the prompt tells the agent to leave them alone.
784
+ const taskDirectory = project.sourceType === 'git' ? project.sourceSettings.directory : undefined;
785
+ const ciFailures = review?.item.ciFailures ? await this.ciFailureLogs(runId, project, review.item.ciFailures, signal) : undefined;
786
+ const result = await this.deps.runAgent({
787
+ adapter: agent.adapter,
788
+ bin: agent.bin,
789
+ cwd: worktreePath,
790
+ prompt: review?.agentReview
791
+ ? buildAgentReviewPrompt({ issue: task, tracker, prUrl: review.item.pr.url, worktreePath, branch, self: review.agentReview.self, taskDirectory })
792
+ : review
793
+ ? buildReviewPrompt({
794
+ issue: task,
795
+ worktreePath,
796
+ repoPath: project.repoPath,
797
+ branch,
798
+ prUrl: review.item.pr.url,
799
+ feedback: review.item.feedback,
800
+ conflictWith: review.item.conflict ? review.item.pr.baseBranch : undefined,
801
+ round: review.round,
802
+ maxRounds: project.reviewMaxRounds,
803
+ tracker,
804
+ ciFailures,
805
+ taskDirectory,
806
+ })
807
+ : buildPrompt({ issue: task, worktreePath, repoPath: project.repoPath, branch, baseRef, tracker, taskDirectory }),
808
+ settings: agent.settings,
809
+ timeoutMs: project.agentTimeoutMinutes * 60_000,
810
+ signal,
811
+ onEvent: (event) => this.log(runId, event.kind, event.text),
812
+ onSpawn: (pid) => this.update(runId, { agentPid: pid }),
813
+ onUsage: (usage) => {
814
+ entry.usageStart ??= usage;
815
+ entry.usageEnd = usage;
816
+ this.recordUsage(agent.id, usage);
817
+ },
818
+ });
819
+ this.update(runId, {
820
+ costUsd: result.costUsd,
821
+ numTurns: result.numTurns,
822
+ // The first report arrives after the run's first request, so this slightly undercounts.
823
+ ...(entry.usageStart && entry.usageEnd ? { usageDelta: usageDelta(entry.usageStart, entry.usageEnd, entry.shared) } : {}),
824
+ });
825
+ if (result.outcome === 'aborted')
826
+ return { kind: 'aborted' };
827
+ if (result.outcome === 'timeout') {
828
+ return { kind: 'failed', reason: `Agent timed out after ${project.agentTimeoutMinutes} minutes` };
829
+ }
830
+ if (result.outcome === 'error') {
831
+ const detail = result.errorMessage ?? result.resultText ?? 'no output';
832
+ return {
833
+ kind: 'failed',
834
+ reason: `Agent failed (exit ${result.exitCode ?? 'none'}): ${truncate(detail, REASON_LIMIT)}`,
835
+ };
836
+ }
837
+ this.update(runId, { step: 'pull_request' });
838
+ if (review) {
839
+ const verdict = review.agentReview ? null : parseVerdict(result.resultText);
840
+ const agentVerdict = review.agentReview ? parseReviewVerdict(result.resultText) : null;
841
+ if (review.agentReview && !agentVerdict)
842
+ return { kind: 'failed', reason: 'Agent review finished without a verdict' };
843
+ if (!review.agentReview && !verdict)
844
+ return { kind: 'failed', reason: 'Agent finished without a verdict on the review feedback' };
845
+ this.update(runId, { prUrl: review.item.pr.url });
846
+ // Everything on the pull request now, including the agent's own replies, counts as dealt with.
847
+ // Otherwise a small clock difference with GitHub could make the agent answer itself.
848
+ let handledUntil = latestOf(review.item.feedback, this.now());
849
+ let headSha = review.item.pr.headSha;
850
+ try {
851
+ const current = await git.pullRequest(project.repoPath, branch, signal);
852
+ if (current) {
853
+ handledUntil = latestOf(current.feedback, this.now());
854
+ headSha = current.headSha;
855
+ }
856
+ }
857
+ catch (error) {
858
+ if (signal.aborted)
859
+ return { kind: 'aborted' };
860
+ this.log(runId, 'foreman', `Could not re-read the pull request: ${errorMessage(error)}`);
861
+ }
862
+ if (agentVerdict) {
863
+ this.log(runId, 'foreman', `Review verdict: ${agentVerdict.verdict}.`);
864
+ return { kind: 'agent_reviewed', verdict: agentVerdict.verdict, findings: agentVerdict.findings, prUrl: review.item.pr.url, handledUntil };
865
+ }
866
+ this.log(runId, 'foreman', `Verdict on the feedback: ${verdict.replace(/_/g, ' ')}.`);
867
+ const ciVerdict = review.item.ciFailures ? parseCiVerdict(result.resultText) : null;
868
+ if (review.item.ciFailures)
869
+ this.log(runId, 'foreman', `Verdict on the failing checks: ${ciVerdict ? ciVerdict.verdict.replace(/_/g, ' ') : 'none given'}.`);
870
+ return { kind: 'reviewed', verdict: verdict, prUrl: review.item.pr.url, handledUntil, headSha, resultText: result.resultText, ciVerdict };
871
+ }
872
+ let prUrl;
873
+ try {
874
+ prUrl = await git.findOpenPr(worktreePath, branch, signal);
875
+ }
876
+ catch (error) {
877
+ if (signal.aborted)
878
+ return { kind: 'aborted' };
879
+ return { kind: 'failed', reason: `Pull request lookup failed: ${errorMessage(error)}` };
880
+ }
881
+ if (!prUrl) {
882
+ const needs = parseBlocked(result.resultText);
883
+ if (needs)
884
+ return { kind: 'blocked', needs, baseRef };
885
+ return { kind: 'failed', reason: 'Agent finished without opening a pull request' };
886
+ }
887
+ this.update(runId, { prUrl });
888
+ this.log(runId, 'foreman', `Found pull request ${prUrl}.`);
889
+ return { kind: 'succeeded', prUrl, resultText: result.resultText };
890
+ }
891
+ async report(runId, task, project, source, outcome, review) {
892
+ const { db, git } = this.deps;
893
+ if (review?.agentReview) {
894
+ await this.reportAgentReview(runId, task, project, source, outcome, review);
895
+ return;
896
+ }
897
+ if (outcome.kind === 'reviewed' && review) {
898
+ const names = source.describe();
899
+ const { item } = review;
900
+ // A conflict fixed on a pull request that was already awaiting approval, with no new feedback
901
+ // to weigh, leaves it awaiting approval.
902
+ const conflictOnly = item.conflict && item.feedback.length === 0 && !item.ciFailures;
903
+ const ciOnly = !!item.ciFailures && item.feedback.length === 0 && !item.conflict;
904
+ const wantsApproval = outcome.verdict === 'approved' || (conflictOnly && item.stage === 'approval');
905
+ // An approval moves the issue only on green checks; otherwise it waits on this head.
906
+ const held = wantsApproval && !(await this.ciGreen(runId, project, item.pr));
907
+ const approved = wantsApproval && !held;
908
+ const target = approved ? names.approval : names.done;
909
+ const before = await progressOf(db, source, task);
910
+ const ciFollow = item.ciFailures
911
+ ? decideCiFollowUp({
912
+ verdict: outcome.ciVerdict ?? null,
913
+ ci: { state: 'failing', checks: item.ciFailures },
914
+ progress: before,
915
+ headBefore: item.pr.headSha,
916
+ headAfter: outcome.headSha,
917
+ })
918
+ : null;
919
+ const sameCiHead = before.ciHead === item.pr.headSha;
920
+ // Recorded first: whatever happens next, this feedback must not be handed out again.
921
+ await saveProgress(db, source, {
922
+ ...before,
923
+ issueId: task.id,
924
+ handledUntil: outcome.handledUntil,
925
+ rounds: review.round,
926
+ handedOver: false,
927
+ // What GitHub showed as the head when a conflict round ended; see CONFLICT_SETTLE_MS.
928
+ conflictHead: review.item.conflict ? outcome.headSha : null,
929
+ conflictAt: review.item.conflict ? this.now().toISOString() : null,
930
+ // An agent reviewer's findings were part of this round, so they are dealt with; its reviewed head stands.
931
+ agentFindings: null,
932
+ approvalHead: held ? outcome.headSha : approved ? null : (before.approvalHead ?? null),
933
+ // The head's failing checks were this round's; a rerun the agent asked for leaves them open until it reports.
934
+ ...(ciFollow
935
+ ? {
936
+ ciHead: item.pr.headSha,
937
+ ciHandled: ciFollow.kind !== 'rerun',
938
+ ciReruns: (sameCiHead ? (before.ciReruns ?? 0) : 0) + (ciFollow.kind === 'rerun' ? 1 : 0),
939
+ ciNotCode: ciFollow.kind === 'rerun' ? ciFollow.reason : sameCiHead ? (before.ciNotCode ?? null) : null,
940
+ }
941
+ : {}),
942
+ });
943
+ try {
944
+ if (approved)
945
+ await source.approve(task);
946
+ else
947
+ await source.complete(task);
948
+ }
949
+ catch (error) {
950
+ const reason = `Review feedback handled (${outcome.prUrl}) but the tracker update failed: ${errorMessage(error)}`;
951
+ this.log(runId, 'foreman', reason);
952
+ const failed = db.finishRun(runId, 'failed', reason);
953
+ this.emit('run', failed);
954
+ await this.commentQuietly(failed, source, task, this.failureComment(failed, `Foreman: run failed.\n\n${reason}.`, source));
955
+ return;
956
+ }
957
+ this.log(runId, 'foreman', `Moved ${task.identifier} to "${target}".`);
958
+ // Back in review: the people (or bots) whose feedback this round answered are asked to look again.
959
+ const rereview = approved ? '' : await this.requestRereview(runId, project, review);
960
+ const resolved = item.conflict && outcome.verdict === 'changes_made' ? `merge conflicts with "${item.pr.baseBranch}" resolved` : null;
961
+ const where = approved ? `Awaiting approval: ${outcome.prUrl}` : `Back in review: ${outcome.prUrl}`;
962
+ const headline = held
963
+ ? `Foreman: the review recommends approval; waiting for its checks to pass before moving it to "${names.approval}": ${outcome.prUrl}`
964
+ : ciOnly
965
+ ? outcome.verdict === 'changes_made'
966
+ ? `Foreman: fixed the failing checks (round ${review.round}). ${where}`
967
+ : `Foreman: looked at the failing checks (round ${review.round}); the agent changed nothing. ${where}`
968
+ : conflictOnly
969
+ ? resolved
970
+ ? `Foreman: ${resolved} (round ${review.round}). ${where}`
971
+ : `Foreman: checked the pull request for merge conflicts (round ${review.round}); the agent changed nothing. ${where}`
972
+ : outcome.verdict === 'approved'
973
+ ? `Foreman: the review recommends approval${resolved ? `, and ${resolved}` : ''}. A person now approves and merges: ${outcome.prUrl}`
974
+ : outcome.verdict === 'changes_made'
975
+ ? `Foreman: review feedback addressed${resolved ? ` and ${resolved}` : ''} (round ${review.round}). ${where}`
976
+ : `Foreman: looked at the new feedback (round ${review.round}); no changes were needed. ${where}`;
977
+ const summary = outcome.resultText
978
+ ? `\n\n${truncate(outcome.resultText.replace(/^[ \t]*FOREMAN_(VERDICT|CI):.*$/gim, '').trim(), RESULT_COMMENT_LIMIT)}`
979
+ : '';
980
+ const reviewed = db.getRun(runId);
981
+ if (reviewed)
982
+ await this.commentQuietly(reviewed, source, task, `${headline}${rereview}${summary}`);
983
+ if (ciFollow && ciFollow.kind !== 'none')
984
+ await this.followUpCi(runId, project, source, task, item, outcome.headSha, ciFollow);
985
+ if (reviewed?.worktreePath) {
986
+ const worktreePath = reviewed.worktreePath;
987
+ try {
988
+ await this.withRepoLock(project.repoPath, () => git.removeWorktree(project.repoPath, worktreePath));
989
+ this.log(runId, 'foreman', `Removed worktree ${worktreePath}.`);
990
+ }
991
+ catch (error) {
992
+ this.log(runId, 'foreman', `Kept worktree ${worktreePath}: ${errorMessage(error)}`);
993
+ }
994
+ }
995
+ this.emit('run', db.finishRun(runId, 'succeeded'));
996
+ return;
997
+ }
998
+ if (outcome.kind === 'succeeded') {
999
+ // Move first: if it fails, the task really is still in progress and the failure comment is accurate.
1000
+ try {
1001
+ await source.complete(task);
1002
+ }
1003
+ catch (error) {
1004
+ const reason = `Pull request opened (${outcome.prUrl}) but the tracker update failed: ${errorMessage(error)}`;
1005
+ this.log(runId, 'foreman', reason);
1006
+ const failed = db.finishRun(runId, 'failed', reason);
1007
+ this.emit('run', failed);
1008
+ await this.commentQuietly(failed, source, task, this.failureComment(failed, `Foreman: run failed.\n\n${reason}.`, source));
1009
+ return;
1010
+ }
1011
+ this.log(runId, 'foreman', `Moved ${task.identifier} to "${source.describe().done}".`);
1012
+ const reviewNote = project.reviewMode === 'copilot' ? ` ${await this.requestCopilotReview(runId, project, task)}` : '';
1013
+ const summary = outcome.resultText ? `\n\n${truncate(outcome.resultText, RESULT_COMMENT_LIMIT)}` : '';
1014
+ const finished = db.getRun(runId);
1015
+ if (finished) {
1016
+ await this.commentQuietly(finished, source, task, `Foreman: agent finished. Pull request: ${outcome.prUrl}${reviewNote}${summary}`);
1017
+ }
1018
+ const run = db.getRun(runId);
1019
+ if (run?.worktreePath) {
1020
+ const worktreePath = run.worktreePath;
1021
+ try {
1022
+ await this.withRepoLock(project.repoPath, () => git.removeWorktree(project.repoPath, worktreePath));
1023
+ this.log(runId, 'foreman', `Removed worktree ${worktreePath}.`);
1024
+ }
1025
+ catch (error) {
1026
+ this.log(runId, 'foreman', `Kept worktree ${worktreePath}: ${errorMessage(error)}`);
1027
+ }
1028
+ }
1029
+ this.emit('run', db.finishRun(runId, 'succeeded'));
1030
+ return;
1031
+ }
1032
+ if (outcome.kind === 'blocked') {
1033
+ await this.reportBlocked(runId, task, project, source, outcome);
1034
+ return;
1035
+ }
1036
+ const aborted = outcome.kind === 'aborted';
1037
+ const reason = aborted ? 'Aborted by operator' : outcome.kind === 'failed' ? outcome.reason : 'Unexpected outcome';
1038
+ this.log(runId, 'foreman', aborted ? 'Run aborted by operator.' : `Run failed: ${reason}`);
1039
+ const run = db.finishRun(runId, aborted ? 'aborted' : 'failed', reason);
1040
+ this.emit('run', run);
1041
+ const headline = aborted ? 'Foreman: run aborted by operator.' : `Foreman: run failed.\n\n${reason}.`;
1042
+ await this.commentQuietly(run, source, task, this.failureComment(run, headline, source));
1043
+ }
1044
+ /**
1045
+ * Records an agent review. Its head counts as reviewed whatever the outcome, so a failed review is
1046
+ * not repeated until something new is pushed. Approval moves the issue on; requested changes wait
1047
+ * for the next round, which hands them to the author's agent. The issue was never claimed, so it stays in review.
1048
+ */
1049
+ async reportAgentReview(runId, task, project, source, outcome, review) {
1050
+ const { db, git } = this.deps;
1051
+ const run = db.getRun(runId);
1052
+ const reviewer = this.deps.getAgents().find((agent) => agent.id === run?.agent)?.adapter.name ?? run?.agent ?? 'The reviewer';
1053
+ const progress = await progressOf(db, source, task);
1054
+ const reviewed = outcome.kind === 'agent_reviewed';
1055
+ await saveProgress(db, source, {
1056
+ ...progress,
1057
+ rounds: review.round,
1058
+ agentReviewedHead: review.item.pr.headSha,
1059
+ ...(reviewed ? { handledUntil: outcome.handledUntil, agentFindings: outcome.verdict === 'changes' ? outcome.findings || '(The reviewer gave no details.)' : null } : {}),
1060
+ });
1061
+ let body;
1062
+ let status = 'succeeded';
1063
+ let reason;
1064
+ if (outcome.kind === 'agent_reviewed' && outcome.verdict === 'approve' && !(await this.ciGreen(runId, project, review.item.pr))) {
1065
+ await saveProgress(db, source, { ...(await progressOf(db, source, task)), approvalHead: review.item.pr.headSha });
1066
+ body = `Foreman: ${reviewer} reviewed the pull request (round ${review.round}) and found nothing that needs changing; waiting for its checks to pass before moving it to "${source.describe().approval}": ${outcome.prUrl}`;
1067
+ }
1068
+ else if (outcome.kind === 'agent_reviewed' && outcome.verdict === 'approve') {
1069
+ try {
1070
+ await source.approve(task);
1071
+ }
1072
+ catch (error) {
1073
+ reason = `Review approved (${outcome.prUrl}) but the tracker update failed: ${errorMessage(error)}`;
1074
+ this.log(runId, 'foreman', reason);
1075
+ status = 'failed';
1076
+ }
1077
+ body = reason
1078
+ ? `Foreman: ${reviewer} found nothing that needs changing, but the issue could not be moved: ${reason}.`
1079
+ : `Foreman: ${reviewer} reviewed the pull request (round ${review.round}) and found nothing that needs changing. Moved to "${source.describe().approval}"; a person now approves and merges it: ${outcome.prUrl}`;
1080
+ }
1081
+ else if (outcome.kind === 'agent_reviewed') {
1082
+ body = `Foreman: ${reviewer} reviewed the pull request (round ${review.round}) and asked for changes; the author's agent takes them next.\n\n${truncate(outcome.findings, RESULT_COMMENT_LIMIT)}`;
1083
+ }
1084
+ else {
1085
+ status = outcome.kind === 'aborted' ? 'aborted' : 'failed';
1086
+ reason = outcome.kind === 'aborted' ? 'Aborted by operator' : outcome.kind === 'failed' ? outcome.reason : 'Unexpected outcome';
1087
+ this.log(runId, 'foreman', `Agent review ${status}: ${reason}`);
1088
+ body = `Foreman: the agent review ${status === 'aborted' ? 'was aborted' : 'failed'}.\n\n${reason}.\n\nThe issue stays in "${source.describe().done}"; the next push to the pull request is reviewed again.`;
1089
+ }
1090
+ const current = db.getRun(runId);
1091
+ if (current)
1092
+ await this.commentQuietly(current, source, task, body);
1093
+ if (current?.worktreePath) {
1094
+ const worktreePath = current.worktreePath;
1095
+ try {
1096
+ await this.withRepoLock(project.repoPath, () => git.removeWorktree(project.repoPath, worktreePath));
1097
+ this.log(runId, 'foreman', `Removed worktree ${worktreePath}.`);
1098
+ }
1099
+ catch (error) {
1100
+ this.log(runId, 'foreman', `Kept worktree ${worktreePath}: ${errorMessage(error)}`);
1101
+ }
1102
+ }
1103
+ this.emit('run', db.finishRun(runId, status, reason));
1104
+ }
1105
+ /** The failing checks of a CI round with an excerpt of each Actions log, fetched once per run. */
1106
+ async ciFailureLogs(runId, project, checks, signal) {
1107
+ const logs = new Map();
1108
+ for (const check of checks) {
1109
+ if (check.runId === null || logs.has(check.runId))
1110
+ continue;
1111
+ const log = await this.deps.git.failedLog(project.repoPath, check.runId, signal);
1112
+ if (!log)
1113
+ this.log(runId, 'foreman', `No log available for the failed run ${check.runId}.`);
1114
+ logs.set(check.runId, log);
1115
+ }
1116
+ return checks.map((check) => ({ name: check.name, link: check.link, log: check.runId === null ? null : (logs.get(check.runId) ?? null) }));
1117
+ }
1118
+ /** Whether an approval may move the issue: always without the CI gate, else only on passing checks or none. A check that cannot be read holds it. */
1119
+ async ciGreen(runId, project, pr) {
1120
+ if (!project.waitForCi)
1121
+ return true;
1122
+ try {
1123
+ const ci = await this.deps.git.ciStatus(project.repoPath, pr.number);
1124
+ return ci.state === 'passing' || ci.state === 'none';
1125
+ }
1126
+ catch (error) {
1127
+ this.log(runId, 'foreman', `Could not read the checks; holding the approval: ${errorMessage(error)}`);
1128
+ return false;
1129
+ }
1130
+ }
1131
+ /** After a CI round: reruns the checks the agent found are not the code's, or hands the issue to a person. */
1132
+ async followUpCi(runId, project, source, task, item, head, follow) {
1133
+ const { db, git } = this.deps;
1134
+ const pr = { ...item.pr, headSha: head };
1135
+ if (follow.kind === 'hand_over') {
1136
+ this.log(runId, 'foreman', `Handing over: ${follow.needs}`);
1137
+ await handOverCi(db, source, task, 'review', pr, follow.needs);
1138
+ return;
1139
+ }
1140
+ const checks = (item.ciFailures ?? []).map((check) => check.name).join(', ');
1141
+ try {
1142
+ for (const id of follow.runIds)
1143
+ await git.rerunFailed(project.repoPath, id);
1144
+ }
1145
+ catch (error) {
1146
+ this.log(runId, 'foreman', `Could not rerun the checks: ${errorMessage(error)}`);
1147
+ await handOverCi(db, source, task, 'review', pr, `checks that could not be rerun (${checks}): ${errorMessage(error)}`);
1148
+ return;
1149
+ }
1150
+ const reruns = (await progressOf(db, source, task)).ciReruns ?? 1;
1151
+ this.log(runId, 'foreman', `Reran ${checks}: ${follow.reason}`);
1152
+ await source
1153
+ .comment(task, `Foreman: reran ${checks} (rerun ${reruns} of ${CI_MAX_RERUNS}) because the agent found the failure is not caused by the code: ${follow.reason}`)
1154
+ .catch(() => { });
1155
+ }
1156
+ /** Asks GitHub Copilot to review a newly opened pull request. Returns a sentence for the tracker comment; never throws. */
1157
+ async requestCopilotReview(runId, project, task) {
1158
+ let note;
1159
+ try {
1160
+ const pr = await this.deps.git.pullRequest(project.repoPath, task.branchName);
1161
+ if (!pr) {
1162
+ note = 'Could not request a Copilot review: no pull request found for the branch.';
1163
+ }
1164
+ else {
1165
+ const { failed } = await this.deps.git.requestReview(project.repoPath, pr.number, [COPILOT_REVIEWER]);
1166
+ note = failed.length > 0 ? `Could not request a Copilot review: ${failed[0].reason}.` : 'Copilot review requested.';
1167
+ }
1168
+ }
1169
+ catch (error) {
1170
+ note = `Could not request a Copilot review: ${errorMessage(error)}.`;
1171
+ }
1172
+ this.log(runId, 'foreman', note);
1173
+ return note;
1174
+ }
1175
+ /**
1176
+ * The agent stopped to ask for something. Its question goes on the issue, which stays claimed, and the
1177
+ * worktree and branch are cleared so that a retry starts clean, unless the agent left work in them.
1178
+ */
1179
+ async reportBlocked(runId, task, project, source, outcome) {
1180
+ const { db, git } = this.deps;
1181
+ this.log(runId, 'foreman', `Agent is blocked: ${outcome.needs}`);
1182
+ const run = db.getRun(runId);
1183
+ const kept = [];
1184
+ if (run?.worktreePath && run.branch) {
1185
+ const { worktreePath, branch } = run;
1186
+ try {
1187
+ await this.withRepoLock(project.repoPath, () => git.removeWorktree(project.repoPath, worktreePath));
1188
+ this.log(runId, 'foreman', `Removed worktree ${worktreePath}.`);
1189
+ try {
1190
+ await this.withRepoLock(project.repoPath, () => git.deleteBranchAt(project.repoPath, branch, outcome.baseRef));
1191
+ this.log(runId, 'foreman', `Deleted branch ${branch}.`);
1192
+ }
1193
+ catch (error) {
1194
+ this.log(runId, 'foreman', `Kept branch ${branch}: ${errorMessage(error)}`);
1195
+ kept.push(`The branch \`${branch}\` has commits, so it was kept; delete it before retrying.`);
1196
+ }
1197
+ }
1198
+ catch (error) {
1199
+ this.log(runId, 'foreman', `Kept worktree ${worktreePath}: ${errorMessage(error)}`);
1200
+ kept.push(`The worktree was kept at \`${worktreePath}\` because it holds files the agent did not commit. Remove it and delete the branch \`${branch}\` before retrying.`);
1201
+ }
1202
+ }
1203
+ const finished = db.finishRun(runId, 'blocked', truncate(outcome.needs, REASON_LIMIT));
1204
+ this.emit('run', finished);
1205
+ const { claimed, waiting } = source.describe();
1206
+ const body = [
1207
+ 'Foreman: the agent needs input before it can go on.',
1208
+ '',
1209
+ truncate(outcome.needs, RESULT_COMMENT_LIMIT),
1210
+ '',
1211
+ `The issue was left in "${claimed}". To retry, add the answer to the issue's description and move it back to "${waiting}".`,
1212
+ ...kept,
1213
+ ].join('\n');
1214
+ await this.commentQuietly(finished, source, task, body);
1215
+ }
1216
+ /**
1217
+ * Asks the authors of a round's feedback to review again. The pull request's own author cannot
1218
+ * review it, so they are left out. Returns a sentence for the tracker comment, or nothing.
1219
+ */
1220
+ async requestRereview(runId, project, review) {
1221
+ const { pr, feedback } = review.item;
1222
+ const reviewers = [...new Set(feedback.map((item) => item.author))].filter((author) => author !== 'unknown' && author !== AGENT_REVIEWER && author.toLowerCase() !== pr.author.toLowerCase());
1223
+ if (reviewers.length === 0)
1224
+ return '';
1225
+ try {
1226
+ const { requested, failed } = await this.deps.git.requestReview(project.repoPath, pr.number, reviewers);
1227
+ for (const { login, reason } of failed)
1228
+ this.log(runId, 'foreman', `Could not request a re-review from ${login}: ${reason}`);
1229
+ if (requested.length === 0)
1230
+ return '';
1231
+ this.log(runId, 'foreman', `Requested a re-review from ${requested.join(', ')}.`);
1232
+ return ` Re-review requested from ${requested.join(', ')}.`;
1233
+ }
1234
+ catch (error) {
1235
+ this.log(runId, 'foreman', `Could not request a re-review: ${errorMessage(error)}`);
1236
+ return '';
1237
+ }
1238
+ }
1239
+ recordUsage(agentId, usage) {
1240
+ this.usage.set(agentId, usage);
1241
+ this.deps.db.setSetting(USAGE_SETTING, Object.fromEntries(this.usage));
1242
+ this.emit('usage', { agentId, usage });
1243
+ }
1244
+ /** Runs `task` after every earlier task for the same repository has finished. */
1245
+ withRepoLock(repoPath, task) {
1246
+ const previous = this.repoLocks.get(repoPath) ?? Promise.resolve();
1247
+ const next = previous.then(task, task);
1248
+ const tail = next.catch(() => { });
1249
+ this.repoLocks.set(repoPath, tail);
1250
+ void tail.then(() => {
1251
+ if (this.repoLocks.get(repoPath) === tail)
1252
+ this.repoLocks.delete(repoPath);
1253
+ });
1254
+ return next;
1255
+ }
1256
+ failureComment(run, headline, source) {
1257
+ const { claimed, waiting } = source.describe();
1258
+ const lines = [headline, '', `The issue was left in "${claimed}"; move it back to "${waiting}" to retry.`];
1259
+ if (run.worktreePath) {
1260
+ lines.push(`The worktree was kept at \`${run.worktreePath}\`. Remove it and delete the branch \`${run.branch}\` before retrying.`);
1261
+ }
1262
+ return lines.join('\n');
1263
+ }
1264
+ async commentQuietly(run, source, task, body) {
1265
+ try {
1266
+ await source.comment(task, body);
1267
+ }
1268
+ catch (error) {
1269
+ this.log(run.id, 'foreman', `Could not comment on ${run.identifier}: ${errorMessage(error)}`);
1270
+ }
1271
+ }
1272
+ update(runId, patch) {
1273
+ this.emit('run', this.deps.db.updateRun(runId, patch));
1274
+ }
1275
+ log(runId, kind, text) {
1276
+ this.emit('log', this.deps.db.addEvent(runId, kind, text));
1277
+ }
1278
+ emitStatus() {
1279
+ this.emit('status', this.getStatus());
1280
+ }
1281
+ }