@bill10/agent-007 0.6.2
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 +21 -0
- package/README.md +222 -0
- package/VERSION +1 -0
- package/bin/adduser.js +69 -0
- package/bin/agent-007.js +88 -0
- package/lib/cron.js +189 -0
- package/lib/helpers.js +541 -0
- package/lib/jobs.js +965 -0
- package/package.json +63 -0
- package/public/app.js +650 -0
- package/public/assets/characters/LICENSE +21 -0
- package/public/assets/characters/char_0.png +0 -0
- package/public/assets/characters/char_1.png +0 -0
- package/public/assets/characters/char_2.png +0 -0
- package/public/assets/characters/char_3.png +0 -0
- package/public/assets/characters/char_4.png +0 -0
- package/public/assets/characters/char_5.png +0 -0
- package/public/assets/furniture/bookshelf.png +0 -0
- package/public/assets/furniture/cactus.png +0 -0
- package/public/assets/furniture/chair_back.png +0 -0
- package/public/assets/furniture/chair_front.png +0 -0
- package/public/assets/furniture/chair_side.png +0 -0
- package/public/assets/furniture/coffee.png +0 -0
- package/public/assets/furniture/coffee_table.png +0 -0
- package/public/assets/furniture/desk.png +0 -0
- package/public/assets/furniture/desk2.png +0 -0
- package/public/assets/furniture/plant_2.png +0 -0
- package/public/assets/furniture/sofa_front.png +0 -0
- package/public/assets/furniture/sofa_side.png +0 -0
- package/public/assets/furniture/table_front.png +0 -0
- package/public/index.html +245 -0
- package/public/modules/auth.js +83 -0
- package/public/modules/explorer.js +760 -0
- package/public/modules/jobs.js +971 -0
- package/public/modules/office.js +2154 -0
- package/public/modules/paths.js +20 -0
- package/public/modules/shortcuts.js +54 -0
- package/public/modules/state.js +75 -0
- package/public/modules/terminal.js +651 -0
- package/public/modules/voice.js +393 -0
- package/public/modules/ws.js +56 -0
- package/public/style.css +1843 -0
- package/server/agent-mcp-bridge.js +45 -0
- package/server/agent-mcp.js +184 -0
- package/server/agent-transcripts.js +195 -0
- package/server/approvals.js +155 -0
- package/server/auth.js +162 -0
- package/server/billion.js +176 -0
- package/server/claude-trust.js +66 -0
- package/server/command-path.js +102 -0
- package/server/config.js +184 -0
- package/server/direct-run.js +33 -0
- package/server/git.js +630 -0
- package/server/http.js +276 -0
- package/server/jobs.js +2044 -0
- package/server/mcp.js +596 -0
- package/server/messages.js +319 -0
- package/server/permission-hook.js +47 -0
- package/server/pty.js +360 -0
- package/server/state.js +104 -0
- package/server/ws.js +583 -0
- package/server.js +306 -0
- package/templates/billion/COMPANY.md +14 -0
- package/templates/billion/STATE.md +17 -0
- package/templates/billion/charter.md +232 -0
- package/templates/billion/owner.md +11 -0
package/server/jobs.js
ADDED
|
@@ -0,0 +1,2044 @@
|
|
|
1
|
+
// Job board — persistence, dispatcher loop, PR watching.
|
|
2
|
+
//
|
|
3
|
+
// The pure logic (schema, dispatch selection, PR parsing, status derivation)
|
|
4
|
+
// lives in lib/jobs.js. This module owns the timers, the git/gh calls, and the
|
|
5
|
+
// job list inside config.json.
|
|
6
|
+
//
|
|
7
|
+
// Dependency direction matches the rest of the server: `broadcast` and
|
|
8
|
+
// `createSession` are passed in rather than imported, so this module never
|
|
9
|
+
// forms a cycle with ws.js or server.js.
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'child_process';
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
13
|
+
import { basename, dirname, join, resolve, sep } from 'path';
|
|
14
|
+
import { config, sessions, orphans, adoptingOrphans, codenamePool, CONFIG_DIR } from './state.js';
|
|
15
|
+
import { saveConfig, syncOrphansToConfig } from './config.js';
|
|
16
|
+
import { gitExec, removeWorktree } from './git.js';
|
|
17
|
+
import { transcriptsFor, codexSessionIdFor } from './agent-transcripts.js';
|
|
18
|
+
import { safeFilename, expandHome } from '../lib/helpers.js';
|
|
19
|
+
import { sendNotice } from './messages.js';
|
|
20
|
+
import { liveBillion } from './billion.js';
|
|
21
|
+
import {
|
|
22
|
+
createJob, selectDispatchableJobs, buildJobCommand, deriveJobStatus,
|
|
23
|
+
parsePrList, parseMergedPr, openPrListArgs, mergedPrListArgs, closedPrViewArgs, parseClosedPr,
|
|
24
|
+
branchSlugFromTitle, isValidPermissionMode, resolveJobPermissionMode, dispatchPermissionMode,
|
|
25
|
+
JOB_STATES,
|
|
26
|
+
DISPATCH_INTERVAL_MS, MAX_AGENTS_PER_REPO, DEFAULT_PERMISSION_MODE,
|
|
27
|
+
MAX_TITLE_LEN, MAX_DETAIL_LEN, isScheduled, jobType, resolveJobType, jobRequiresPr,
|
|
28
|
+
scheduleHold, supersededRuns, createRunJob, runsToPrune, defaultRequiresPr, isJobDue, STATE_LABELS,
|
|
29
|
+
jobAgent, jobAgentFromCommand, resolveJobAgent, resumeCommand, isValidJobAgent, recordedPermissionFlags,
|
|
30
|
+
BILLION_NAME, envPermissionMode,
|
|
31
|
+
} from '../lib/jobs.js';
|
|
32
|
+
import { nextCronIso } from '../lib/cron.js';
|
|
33
|
+
|
|
34
|
+
// --- Board settings ---
|
|
35
|
+
|
|
36
|
+
function defaultSettings() {
|
|
37
|
+
return {
|
|
38
|
+
// Off until the user presses Start. An unattended process that types into
|
|
39
|
+
// agents and creates worktrees should not begin on its own the first time
|
|
40
|
+
// the app is opened.
|
|
41
|
+
running: false,
|
|
42
|
+
maxPerRepo: MAX_AGENTS_PER_REPO,
|
|
43
|
+
intervalMs: DISPATCH_INTERVAL_MS,
|
|
44
|
+
permissionMode: DEFAULT_PERMISSION_MODE,
|
|
45
|
+
// Whether a human ever picked that mode in the toolbar. Written only by
|
|
46
|
+
// updateSettings; see boardSettings() for why the flag has to exist.
|
|
47
|
+
permissionModeChosen: false,
|
|
48
|
+
// Whether the legacy-value migration in boardSettings() has already run.
|
|
49
|
+
// Separate from `permissionModeChosen` on purpose: that one means a person
|
|
50
|
+
// decided, and setting it here would be a lie that also stops any future
|
|
51
|
+
// default from ever reaching this board.
|
|
52
|
+
permissionModeMigrated: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The board's mode for a card on this CLI, before the card's own mode: one
|
|
57
|
+
// picked in the board's dropdown wins; with none picked, the CLI's .env
|
|
58
|
+
// default (CLAUDE_PERMISSION_MODE / CODEX_PERMISSION_MODE); then the board's
|
|
59
|
+
// built-in default.
|
|
60
|
+
export function boardModeFor(agent) {
|
|
61
|
+
const settings = boardSettings();
|
|
62
|
+
if (settings.permissionModeChosen) return settings.permissionMode;
|
|
63
|
+
return envPermissionMode(agent) || settings.permissionMode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function boardSettings() {
|
|
67
|
+
if (!config.jobBoard || typeof config.jobBoard !== 'object') config.jobBoard = defaultSettings();
|
|
68
|
+
else config.jobBoard = { ...defaultSettings(), ...config.jobBoard };
|
|
69
|
+
// Migrate the legacy value ONCE. Until the toolbar got a permission-mode
|
|
70
|
+
// control there was no way to choose one, so every mode sitting in
|
|
71
|
+
// ~/.agent-007/config.json today was written by the first dispatcher start
|
|
72
|
+
// out of whatever DEFAULT_PERMISSION_MODE was then. The stored object is
|
|
73
|
+
// spread OVER the defaults, so without this a changed default reaches
|
|
74
|
+
// nobody. Unchosen therefore reads as unset, and the current default wins.
|
|
75
|
+
//
|
|
76
|
+
// The `permissionModeMigrated` guard is what makes "once" true. Keying the
|
|
77
|
+
// reset on `permissionModeChosen` alone re-ran it on EVERY call, so a mode
|
|
78
|
+
// hand-edited into config.json was silently overwritten on the next read,
|
|
79
|
+
// for ever — and hand-editing that file is what every doc said to do before
|
|
80
|
+
// this release, and is still the only route on a box with no browser. The
|
|
81
|
+
// symptom was the worst kind: the file says one thing and the board
|
|
82
|
+
// dispatches another, with nothing logged.
|
|
83
|
+
if (!config.jobBoard.permissionModeMigrated) {
|
|
84
|
+
if (!config.jobBoard.permissionModeChosen) config.jobBoard.permissionMode = DEFAULT_PERMISSION_MODE;
|
|
85
|
+
config.jobBoard.permissionModeMigrated = true;
|
|
86
|
+
// Written through immediately, and this is the only place boardSettings()
|
|
87
|
+
// is allowed to write. A marker that lives only in memory is not a marker:
|
|
88
|
+
// a board with no queued cards never reaches the dispatcher's persist, so
|
|
89
|
+
// the flag would die with the process and the migration would run again on
|
|
90
|
+
// the next boot -- reverting exactly the hand-edit it is meant to respect.
|
|
91
|
+
// saveConfig only serialises `config`, so there is no recursion back here,
|
|
92
|
+
// and the branch cannot be taken twice.
|
|
93
|
+
saveConfig();
|
|
94
|
+
}
|
|
95
|
+
// Always, whenever it arrived: an unknown mode would show up as a blank
|
|
96
|
+
// select, and buildJobCommand would fall back to the default anyway.
|
|
97
|
+
if (!isValidPermissionMode(config.jobBoard.permissionMode)) {
|
|
98
|
+
config.jobBoard.permissionMode = DEFAULT_PERMISSION_MODE;
|
|
99
|
+
}
|
|
100
|
+
return config.jobBoard;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function allJobs() {
|
|
104
|
+
if (!Array.isArray(config.jobs)) config.jobs = [];
|
|
105
|
+
return config.jobs;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Broadcast ---
|
|
109
|
+
|
|
110
|
+
// The wire shape adds `status` (running / needs-input / stalled / gone) and the
|
|
111
|
+
// live agent state, both derived fresh on every send. They are never persisted:
|
|
112
|
+
// they describe a PTY that exists right now, so a stored copy would go stale
|
|
113
|
+
// the moment the server restarts.
|
|
114
|
+
export function jobsPayload() {
|
|
115
|
+
const jobs = allJobs().map((job) => {
|
|
116
|
+
const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
|
|
117
|
+
return {
|
|
118
|
+
...job,
|
|
119
|
+
// Resolved rather than raw: cards written before scheduled jobs existed
|
|
120
|
+
// carry no type at all, and the client should not have to know that.
|
|
121
|
+
type: jobType(job),
|
|
122
|
+
status: deriveJobStatus(job, session),
|
|
123
|
+
agentState: session ? session.state : null,
|
|
124
|
+
agentAlive: !!(session && !session.exited),
|
|
125
|
+
};
|
|
126
|
+
});
|
|
127
|
+
// The .env defaults ride along, so the toolbar can show the mode workers
|
|
128
|
+
// really start in while no mode has been picked there.
|
|
129
|
+
const envModes = { claude: envPermissionMode('claude'), codex: envPermissionMode('codex') };
|
|
130
|
+
return { type: 'jobs-list', jobs, settings: { ...boardSettings(), envModes } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function broadcastJobs(broadcast) {
|
|
134
|
+
if (broadcast) broadcast(jobsPayload());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function persist(broadcast) {
|
|
138
|
+
saveConfig(broadcast);
|
|
139
|
+
broadcastJobs(broadcast);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- PR-check notes ---
|
|
143
|
+
|
|
144
|
+
// Why the board cannot see this job's pull request, written onto the card.
|
|
145
|
+
// Shared by both sweeps: they ask GitHub different questions but report the
|
|
146
|
+
// answer the same way, under the same rule — write only when the text changes,
|
|
147
|
+
// so a permanent failure does not rewrite config.json every scan. Each returns
|
|
148
|
+
// whether it actually changed anything, so a caller can decide to persist.
|
|
149
|
+
function notePrCheckError(job, message) {
|
|
150
|
+
if (job.prCheckError === message) return false;
|
|
151
|
+
job.prCheckError = message;
|
|
152
|
+
job.prCheckErrorAt = new Date().toISOString();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearPrCheckError(job) {
|
|
157
|
+
if (!job.prCheckError) return false;
|
|
158
|
+
job.prCheckError = null;
|
|
159
|
+
job.prCheckErrorAt = null;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Attachments ---
|
|
164
|
+
|
|
165
|
+
// Files posted with a card live under the config dir, never in the repo, so
|
|
166
|
+
// the agent reads them by absolute path and nothing can end up committed. They
|
|
167
|
+
// arrive inline on the job message as base64, the same shape as the terminal's
|
|
168
|
+
// upload-file, so the id is known before anything touches the disk.
|
|
169
|
+
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
|
170
|
+
const MAX_ATTACHMENTS = 20;
|
|
171
|
+
// Per save, not per card: one message is one WebSocket frame, and ws drops
|
|
172
|
+
// the socket (not the message) past its 100MiB default. Bounded well under
|
|
173
|
+
// that, base64 included, so the failure a user sees is a form error rather
|
|
174
|
+
// than a vanished card.
|
|
175
|
+
const MAX_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024;
|
|
176
|
+
const ATTACHMENTS_DIR = resolve(CONFIG_DIR, 'attachments');
|
|
177
|
+
|
|
178
|
+
function attachmentDir(jobId) {
|
|
179
|
+
return join(ATTACHMENTS_DIR, jobId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Stored paths and ids come from config.json, which a person (or an agent
|
|
183
|
+
// running as the same user) can edit by hand, so nothing is served or deleted
|
|
184
|
+
// through one unless it resolves inside the attachments dir.
|
|
185
|
+
function insideAttachments(path) {
|
|
186
|
+
return resolve(String(path || '')).startsWith(ATTACHMENTS_DIR + sep);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The full list the card should have: { name } keeps an existing file, { name,
|
|
190
|
+
// data } writes a new one, and anything on disk that is not named is removed.
|
|
191
|
+
// Split in two so updateJob can refuse before it has touched the job: plan
|
|
192
|
+
// decodes and validates without side effects, apply writes. Returns null when
|
|
193
|
+
// the message did not mention attachments at all.
|
|
194
|
+
function planAttachments(job, list) {
|
|
195
|
+
if (!Array.isArray(list)) return null;
|
|
196
|
+
if (list.length > MAX_ATTACHMENTS) return { error: `At most ${MAX_ATTACHMENTS} files per job` };
|
|
197
|
+
const dir = attachmentDir(job.id);
|
|
198
|
+
if (!insideAttachments(dir)) return { error: 'Job id cannot hold attachments' };
|
|
199
|
+
const stored = job.attachments || [];
|
|
200
|
+
const kept = [];
|
|
201
|
+
const writes = [];
|
|
202
|
+
let total = 0;
|
|
203
|
+
for (const item of list) {
|
|
204
|
+
// No separator survives the sanitiser and a name with no letter or digit
|
|
205
|
+
// is refused, so join() cannot climb out of the job's dir. Every refusal
|
|
206
|
+
// is an error, not a silent drop: the user attached the file on purpose.
|
|
207
|
+
const name = safeFilename(item?.name).slice(0, 120);
|
|
208
|
+
if (!/[a-zA-Z0-9]/.test(name)) return { error: `Unusable file name "${String(item?.name || '')}"` };
|
|
209
|
+
// Case-insensitive: macOS and Windows would store Shot.png and shot.png
|
|
210
|
+
// as one file that two records then fight over.
|
|
211
|
+
if (kept.some(a => a.name.toLowerCase() === name.toLowerCase())) return { error: `Two files would be stored as "${name}"` };
|
|
212
|
+
const path = join(dir, name);
|
|
213
|
+
if (typeof item.data === 'string') {
|
|
214
|
+
const buf = Buffer.from(item.data, 'base64');
|
|
215
|
+
if (buf.length > MAX_ATTACHMENT_BYTES) return { error: `${name} is too large (max 10MB)` };
|
|
216
|
+
total += buf.length;
|
|
217
|
+
if (total > MAX_ATTACHMENT_TOTAL_BYTES) return { error: 'Attachments add up to more than 50MB' };
|
|
218
|
+
writes.push({ path, buf });
|
|
219
|
+
kept.push({ name, path });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// A { name } keeps what the card already has, record and all: a file
|
|
223
|
+
// that has gone missing from disk stays listed (its link 404s, which
|
|
224
|
+
// says so) rather than being dropped from the card by an unrelated edit.
|
|
225
|
+
const old = stored.find(a => a.name === name);
|
|
226
|
+
if (old) kept.push(old);
|
|
227
|
+
}
|
|
228
|
+
return { dir, kept, writes };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function applyAttachments(job, plan) {
|
|
232
|
+
// Written beside the final name and renamed into place only once every
|
|
233
|
+
// write has succeeded, so a write that fails (disk full) leaves the files
|
|
234
|
+
// the card already had as they were. The suffix holds a character the
|
|
235
|
+
// sanitiser strips, so it can never be a real attachment's name.
|
|
236
|
+
// ponytail: the rename loop itself is not atomic as a set; a rename that
|
|
237
|
+
// fails mid-loop (Windows EPERM on an open file) leaves earlier ones done.
|
|
238
|
+
const parts = plan.writes.map(w => ({ ...w, part: `${w.path}~part` }));
|
|
239
|
+
try {
|
|
240
|
+
if (parts.length) mkdirSync(plan.dir, { recursive: true });
|
|
241
|
+
for (const w of parts) writeFileSync(w.part, w.buf);
|
|
242
|
+
for (const w of parts) renameSync(w.part, w.path);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
for (const w of parts) rmSync(w.part, { force: true });
|
|
245
|
+
return { error: `Could not save attachments: ${err.message}` };
|
|
246
|
+
}
|
|
247
|
+
for (const old of job.attachments || []) {
|
|
248
|
+
if (!plan.kept.some(a => a.name === old.name) && insideAttachments(old.path)) rmSync(old.path, { force: true });
|
|
249
|
+
}
|
|
250
|
+
return { attachments: plan.kept };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// For the download route: the stored path, or null if the card has no such file.
|
|
254
|
+
export function attachmentPath(jobId, name) {
|
|
255
|
+
const job = allJobs().find(j => j.id === jobId);
|
|
256
|
+
const hit = job && (job.attachments || []).find(a => a.name === name);
|
|
257
|
+
return hit && insideAttachments(hit.path) ? hit.path : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A card is a few lines of JSON and stays forever; its files can be megabytes
|
|
261
|
+
// and were inputs to a run that is now over. Done is terminal, so the moment a
|
|
262
|
+
// card gets there — by hand or by the merge sweep — the disk comes back. The
|
|
263
|
+
// records go with the files: an archive card full of 404 links says less than
|
|
264
|
+
// none. On a failure (or a path the containment check refuses) the list is
|
|
265
|
+
// kept, so the files stay owned by the card and still go when it is deleted.
|
|
266
|
+
// Returns whether anything changed, so callers know to persist.
|
|
267
|
+
function clearAttachments(job) {
|
|
268
|
+
if (!(job.attachments || []).length) return false;
|
|
269
|
+
const dir = attachmentDir(job.id);
|
|
270
|
+
if (!insideAttachments(dir)) return false;
|
|
271
|
+
try {
|
|
272
|
+
rmSync(dir, { recursive: true, force: true });
|
|
273
|
+
job.attachments = [];
|
|
274
|
+
return true;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error(`Failed to remove attachments for finished job "${job.title}":`, err.message);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// True while a live agent may still be reading this card's files: its prompt
|
|
282
|
+
// named their absolute paths at dispatch — the same hazard updateJob's To-do
|
|
283
|
+
// gate exists for — and a Review agent is kept until its card is done, so a
|
|
284
|
+
// kill that failed at Done leaves it holding the files until it exits.
|
|
285
|
+
function attachmentsInUse(job) {
|
|
286
|
+
const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
|
|
287
|
+
return !!session && !session.exited;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Done cards whose files are still on disk: a clear the OS refused (a file
|
|
291
|
+
// open in a browser tab on Windows), or one deferred while the card's agent
|
|
292
|
+
// was still alive. Done is terminal, so without this per-sweep retry a single
|
|
293
|
+
// failure would leak the directory for as long as the archive keeps the card.
|
|
294
|
+
function clearFinishedAttachments() {
|
|
295
|
+
let cleared = false;
|
|
296
|
+
for (const job of allJobs()) {
|
|
297
|
+
if (job.state === 'done' && (job.attachments || []).length && !attachmentsInUse(job)) {
|
|
298
|
+
if (clearAttachments(job)) cleared = true;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return cleared;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- CRUD ---
|
|
305
|
+
|
|
306
|
+
export function addJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion, attachments }, broadcast) {
|
|
307
|
+
const result = createJob({ title, detail, repoPath, type, schedule, permissionMode, agent, requiresPr, postedBy, postedByName, postedByAgent, postedByBillion });
|
|
308
|
+
if (result.error) return result;
|
|
309
|
+
const plan = planAttachments(result.job, attachments);
|
|
310
|
+
if (plan?.error) return plan;
|
|
311
|
+
const written = plan ? applyAttachments(result.job, plan) : null;
|
|
312
|
+
if (written?.error) return written;
|
|
313
|
+
if (written) result.job.attachments = written.attachments;
|
|
314
|
+
allJobs().push(result.job);
|
|
315
|
+
persist(broadcast);
|
|
316
|
+
return { job: result.job };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- Repo references ---
|
|
320
|
+
|
|
321
|
+
// The board's own form picks a repo from a dropdown of exact paths. An agent
|
|
322
|
+
// calling the MCP tool types whatever it knows — "~/code/app", a relative path,
|
|
323
|
+
// or just the folder name. Resolve all of those against the configured repos,
|
|
324
|
+
// and refuse anything that doesn't match rather than queueing a job into a repo
|
|
325
|
+
// the dispatcher will silently skip forever.
|
|
326
|
+
export function resolveRepoRef(ref) {
|
|
327
|
+
const repos = Array.isArray(config.repos) ? config.repos : [];
|
|
328
|
+
const known = repos.map(r => r.path);
|
|
329
|
+
if (repos.length === 0) return { error: 'No repositories are configured in Agent 007 — add one in the explorer first' };
|
|
330
|
+
const raw = String(ref || '').trim();
|
|
331
|
+
if (!raw) return { error: `Which repository? Pass repo with one of: ${known.map(p => basename(p)).join(', ')}` };
|
|
332
|
+
const expanded = expandHome(raw);
|
|
333
|
+
const abs = resolve(expanded);
|
|
334
|
+
const byPath = known.find(p => p === raw || p === abs);
|
|
335
|
+
if (byPath) return { path: byPath };
|
|
336
|
+
// Folder name, case-insensitively. Ambiguity is possible (two repos sharing a
|
|
337
|
+
// basename), so say so instead of guessing.
|
|
338
|
+
const lower = raw.toLowerCase();
|
|
339
|
+
const byName = known.filter(p => basename(p).toLowerCase() === lower);
|
|
340
|
+
if (byName.length === 1) return { path: byName[0] };
|
|
341
|
+
if (byName.length > 1) {
|
|
342
|
+
// Enough to tell them apart (the parent folder), not the absolute path:
|
|
343
|
+
// every other branch here reports basenames only, and this reply goes out
|
|
344
|
+
// to whatever called the tool.
|
|
345
|
+
const hints = byName.map(p => join(basename(dirname(p)), basename(p)));
|
|
346
|
+
return { error: `"${raw}" matches more than one repository (${hints.join(', ')}) — pass the full path` };
|
|
347
|
+
}
|
|
348
|
+
return { error: `Unknown repository "${raw}" — known repositories: ${known.map(p => basename(p)).join(', ')}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --- Posting on an agent's behalf ---
|
|
352
|
+
//
|
|
353
|
+
// The one place a card gets created for an agent, shared by the MCP tool and by
|
|
354
|
+
// POST /api/jobs. Both doors must resolve the repo, attribute the card and
|
|
355
|
+
// report dispatcher state identically; a second copy of this would drift.
|
|
356
|
+
//
|
|
357
|
+
// Not ownership-gated, matching the WebSocket `job-create` handler: the board is
|
|
358
|
+
// shared workspace state, and postedBy/postedByAgent are attribution rather than
|
|
359
|
+
// access control.
|
|
360
|
+
// Said the same way at both doors: an agent that passed a JSON number or an
|
|
361
|
+
// object meant to schedule something, and deserves the same answer either way.
|
|
362
|
+
function scheduleTypeError(schedule) {
|
|
363
|
+
return schedule != null && typeof schedule !== 'string'
|
|
364
|
+
? 'schedule must be a string — a five-field cron expression or an @shorthand'
|
|
365
|
+
: null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function postJobForAgent({ title, detail, repo, schedule, type, agent, requiresPr, session, user }, broadcast) {
|
|
369
|
+
// The repo the calling agent is working in is the overwhelmingly likely
|
|
370
|
+
// answer, so an agent only names one when it means a different repo.
|
|
371
|
+
const resolved = resolveRepoRef(repo || (session && session.repoPath) || '');
|
|
372
|
+
if (resolved.error) return { error: resolved.error };
|
|
373
|
+
|
|
374
|
+
// A non-string schedule (a JSON number, an object) must come back as an
|
|
375
|
+
// error, not a silent one-time card: the plain HTTP door has no schema in
|
|
376
|
+
// front of it, the caller asked for a scheduled job, and every other bad
|
|
377
|
+
// input here is answered with a message the caller can act on.
|
|
378
|
+
const badSchedule = scheduleTypeError(schedule);
|
|
379
|
+
if (badSchedule) return { error: badSchedule };
|
|
380
|
+
if (type != null && typeof type !== 'string') {
|
|
381
|
+
return { error: 'type must be a string — "one-time" or "scheduled"' };
|
|
382
|
+
}
|
|
383
|
+
if (agent != null && typeof agent !== 'string') {
|
|
384
|
+
return { error: 'agent must be a string — "claude" or "codex"' };
|
|
385
|
+
}
|
|
386
|
+
if (requiresPr != null && typeof requiresPr !== 'boolean') {
|
|
387
|
+
return { error: 'requires_pr must be true or false' };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const result = addJob({
|
|
391
|
+
// Typed explicitly: this comes off the wire, and a non-string would be
|
|
392
|
+
// stringified into the card ("[object Object]") rather than rejected.
|
|
393
|
+
// createJob still owns the length and emptiness rules.
|
|
394
|
+
title: typeof title === 'string' ? title : '',
|
|
395
|
+
detail: typeof detail === 'string' ? detail : '',
|
|
396
|
+
repoPath: resolved.path,
|
|
397
|
+
// A schedule alone makes it a scheduled job — createJob owns that rule, and
|
|
398
|
+
// the cron validation with it, so a bad expression comes back as a message
|
|
399
|
+
// the calling agent can act on rather than a card that never fires.
|
|
400
|
+
type: typeof type === 'string' ? type : undefined,
|
|
401
|
+
schedule: typeof schedule === 'string' ? schedule : '',
|
|
402
|
+
// Unnamed, the card runs on the same CLI as the agent posting it; a person
|
|
403
|
+
// at the HTTP door with no session gets the board default.
|
|
404
|
+
agent: agent || (session ? jobAgentFromCommand(session.command) : undefined),
|
|
405
|
+
requiresPr,
|
|
406
|
+
// No permissionMode: an agent posting a card must not be able to pick the
|
|
407
|
+
// mode the board will spawn with, which would be a way around every gate
|
|
408
|
+
// its own session runs under. A card an agent files inherits the board's.
|
|
409
|
+
postedBy: user ? user.id : null,
|
|
410
|
+
postedByName: user ? user.displayName : null,
|
|
411
|
+
postedByAgent: session ? session.name : null,
|
|
412
|
+
postedByBillion: !!session?.isBillion,
|
|
413
|
+
}, broadcast);
|
|
414
|
+
if (result.error) return { error: result.error };
|
|
415
|
+
|
|
416
|
+
// A card an agent posted while the user was looking at a terminal would
|
|
417
|
+
// otherwise land silently on a tab they cannot see. The board's own form
|
|
418
|
+
// needs no toast — the user is looking straight at the column it lands in.
|
|
419
|
+
if (session && broadcast) {
|
|
420
|
+
broadcast({
|
|
421
|
+
type: 'notification', level: 'info',
|
|
422
|
+
message: `${session.name} posted a job: "${result.job.title}"`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
job: result.job,
|
|
427
|
+
repoName: basename(resolved.path),
|
|
428
|
+
// Whether the board will actually act on it. Reported out loud, because a
|
|
429
|
+
// stopped dispatcher means the card just sits in To do — and an agent
|
|
430
|
+
// telling its user "queued" would imply work is under way when none is.
|
|
431
|
+
dispatcherRunning: !!boardSettings().running,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// --- The read side of the same door ---
|
|
436
|
+
//
|
|
437
|
+
// An agent asked by its user to look at the board, rather than write to it. The
|
|
438
|
+
// board is one shared wall — jobsPayload sends every card to every connected
|
|
439
|
+
// client — so there is nothing per-agent to filter out here, and these read the
|
|
440
|
+
// same store the browser does.
|
|
441
|
+
|
|
442
|
+
// The row an agent gets per card: enough to say what the card is and to name it
|
|
443
|
+
// in a follow-up call, without the detail body, which is the long part.
|
|
444
|
+
function jobSummary(job) {
|
|
445
|
+
const session = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
|
|
446
|
+
return {
|
|
447
|
+
id: job.id,
|
|
448
|
+
title: job.title,
|
|
449
|
+
state: job.state,
|
|
450
|
+
type: jobType(job),
|
|
451
|
+
agent: jobAgent(job),
|
|
452
|
+
requiresPr: jobRequiresPr(job),
|
|
453
|
+
schedule: job.schedule || null,
|
|
454
|
+
nextRunAt: job.nextRunAt || null,
|
|
455
|
+
scheduleId: job.scheduleId || null,
|
|
456
|
+
lastSkipReason: job.lastSkipReason || null,
|
|
457
|
+
repo: basename(job.repoPath || ''),
|
|
458
|
+
editedByAgent: job.editedByAgent || null,
|
|
459
|
+
// Derived fresh, never stored: it describes a PTY that exists right now.
|
|
460
|
+
status: deriveJobStatus(job, session),
|
|
461
|
+
agentName: job.agentName || null,
|
|
462
|
+
prUrl: job.prUrl || null,
|
|
463
|
+
postedByName: job.postedByName || null,
|
|
464
|
+
postedByAgent: job.postedByAgent || null,
|
|
465
|
+
postedAt: job.postedAt || null,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function listJobsForAgent({ state, repo } = {}) {
|
|
470
|
+
const wanted = typeof state === 'string' && state.trim() ? state.trim() : null;
|
|
471
|
+
if (wanted && !JOB_STATES.includes(wanted)) {
|
|
472
|
+
return { error: `Unknown state "${wanted}" — expected one of: ${JOB_STATES.join(', ')}` };
|
|
473
|
+
}
|
|
474
|
+
// Narrowing arguments must narrow or refuse, never widen: a non-string repo
|
|
475
|
+
// used to fall through to "every repository", which is the opposite of what
|
|
476
|
+
// the caller asked for. postJobForAgent refuses the same shape.
|
|
477
|
+
if (repo != null && typeof repo !== 'string') {
|
|
478
|
+
return { error: 'repo must be a string — a full path or a folder name' };
|
|
479
|
+
}
|
|
480
|
+
let repoPath = null;
|
|
481
|
+
if (typeof repo === 'string' && repo.trim()) {
|
|
482
|
+
const resolved = resolveRepoRef(repo);
|
|
483
|
+
if (resolved.error) return { error: resolved.error };
|
|
484
|
+
repoPath = resolved.path;
|
|
485
|
+
}
|
|
486
|
+
// Finished cards are the archive behind the board's toolbar, not a column, so
|
|
487
|
+
// an unfiltered list answers with the board — asking for `done` reaches them.
|
|
488
|
+
const jobs = allJobs().filter(job => (wanted ? job.state === wanted : job.state !== 'done')
|
|
489
|
+
&& (!repoPath || job.repoPath === repoPath))
|
|
490
|
+
// Oldest first, the order the board's own columns are sorted in, so an
|
|
491
|
+
// agent describes the board the user is looking at rather than a shuffle.
|
|
492
|
+
.sort((a, b) => String(a.postedAt || '').localeCompare(String(b.postedAt || '')));
|
|
493
|
+
return {
|
|
494
|
+
jobs: jobs.map(jobSummary),
|
|
495
|
+
state: wanted,
|
|
496
|
+
repoName: repoPath ? basename(repoPath) : null,
|
|
497
|
+
// Said out loud by the caller: a board that looks empty because the archive
|
|
498
|
+
// is hidden is different from a board with nothing on it.
|
|
499
|
+
archived: wanted ? 0 : allJobs().filter(job => job.state === 'done').length,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export function readJobForAgent(jobId) {
|
|
504
|
+
const job = allJobs().find(j => j.id === jobId);
|
|
505
|
+
if (!job) return { error: `No job with id "${jobId}" — list the board to see the ids.` };
|
|
506
|
+
return {
|
|
507
|
+
job: {
|
|
508
|
+
...jobSummary(job),
|
|
509
|
+
detail: job.detail || '',
|
|
510
|
+
// Basename only, deliberately — resolveRepoRef reports repos the same
|
|
511
|
+
// way. An absolute repo or worktree path is the layout of the user's
|
|
512
|
+
// disk, and this reply goes to whatever agent called the tool, about
|
|
513
|
+
// every card on the board including other people's.
|
|
514
|
+
branchName: job.branchName || null,
|
|
515
|
+
startedAt: job.startedAt || null,
|
|
516
|
+
prMergedAt: job.prMergedAt || null,
|
|
517
|
+
prClosedAt: job.prClosedAt || null,
|
|
518
|
+
resultSummary: job.resultSummary || null,
|
|
519
|
+
editedAt: job.editedAt || null,
|
|
520
|
+
lastRunAt: job.lastRunAt || null,
|
|
521
|
+
runCount: job.runCount || 0,
|
|
522
|
+
// Both, and separately: the board keeps them apart because a card can be
|
|
523
|
+
// failing to dispatch AND failing its PR check at once.
|
|
524
|
+
lastError: job.lastError || null,
|
|
525
|
+
prCheckError: job.prCheckError || null,
|
|
526
|
+
attachments: (Array.isArray(job.attachments) ? job.attachments : []).map(a => a && a.name).filter(Boolean),
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The same edit, asked for by an agent. It refuses ahead of updateJob rather
|
|
532
|
+
// than after it so a no-op edit to a dispatched card answers with the rule that
|
|
533
|
+
// actually stopped it, not "nothing to change".
|
|
534
|
+
//
|
|
535
|
+
// Two guards here that the board's own form does not need. A To do card's
|
|
536
|
+
// detail IS the next agent's prompt — buildJobPrompt hands it over verbatim to
|
|
537
|
+
// an unattended `claude --permission-mode auto` — and every board-dispatched
|
|
538
|
+
// agent holds one of these tokens, so an agent working a hostile repo could
|
|
539
|
+
// otherwise rewrite a card queued for a different repo and have the board run
|
|
540
|
+
// its text. So: an agent may not touch a card another person queued, matching
|
|
541
|
+
// the ownership rule ws.js already applies to terminals; and an edit that does
|
|
542
|
+
// land says so out loud and leaves its name on the card, because the whole
|
|
543
|
+
// hazard is an edit nobody sees. Reading stays board-wide — every browser
|
|
544
|
+
// already sees every card — but writing does not.
|
|
545
|
+
export function editJobForAgent({ id, title, detail, repo, schedule, requiresPr, session, user }, broadcast) {
|
|
546
|
+
const job = allJobs().find(j => j.id === id);
|
|
547
|
+
if (!job) return { error: `No job with id "${id}" — list the board to see the ids.` };
|
|
548
|
+
const gate = editableInPlace(job);
|
|
549
|
+
if (gate) return gate;
|
|
550
|
+
// Null on a single-player board (no users file), on both sides, so this only
|
|
551
|
+
// ever bites once identities exist.
|
|
552
|
+
const asker = user ? user.id : null;
|
|
553
|
+
if (job.postedBy && job.postedBy !== asker) {
|
|
554
|
+
return {
|
|
555
|
+
error: `"${job.title}" was queued by ${job.postedByName || 'someone else'}, so this agent cannot edit it. `
|
|
556
|
+
+ 'Ask them, or post a new card.',
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
// Billion's cards carry its trust — its worker asks Billion, not a person,
|
|
560
|
+
// for permission — and belong to no user, so the check above never fires on
|
|
561
|
+
// them. Only Billion rewrites them; a person still can, from the board.
|
|
562
|
+
if (job.postedByBillion && !session?.isBillion) {
|
|
563
|
+
return { error: `"${job.title}" is ${BILLION_NAME}'s card, so only ${BILLION_NAME} can edit it. Ask it with send_message, or post a new card.` };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const fields = {};
|
|
567
|
+
const changed = [];
|
|
568
|
+
if (title !== undefined) {
|
|
569
|
+
if (typeof title !== 'string' || !title.trim()) {
|
|
570
|
+
return { error: 'Title must be a non-empty string — omit it to leave the title alone.' };
|
|
571
|
+
}
|
|
572
|
+
if (title.trim() !== job.title) { fields.title = title; changed.push('title'); }
|
|
573
|
+
}
|
|
574
|
+
if (detail !== undefined) {
|
|
575
|
+
if (typeof detail !== 'string') return { error: 'detail must be a string' };
|
|
576
|
+
if (detail.trim() !== (job.detail || '')) { fields.detail = detail; changed.push('detail'); }
|
|
577
|
+
}
|
|
578
|
+
if (repo !== undefined) {
|
|
579
|
+
const resolved = resolveRepoRef(repo);
|
|
580
|
+
if (resolved.error) return { error: resolved.error };
|
|
581
|
+
if (resolved.path !== job.repoPath) { fields.repoPath = resolved.path; changed.push('repo'); }
|
|
582
|
+
}
|
|
583
|
+
if (schedule !== undefined) {
|
|
584
|
+
const bad = scheduleTypeError(schedule);
|
|
585
|
+
if (bad) return { error: bad };
|
|
586
|
+
const text = String(schedule || '').trim();
|
|
587
|
+
// An empty schedule is the way back to a one-time card: without naming the
|
|
588
|
+
// type too, resolveJobType would read "scheduled with no cron" and refuse.
|
|
589
|
+
// The tool exposes no `type` of its own — the schedule is the whole of what
|
|
590
|
+
// a card IS, and two fields that can disagree is a bug waiting to be filed.
|
|
591
|
+
fields.schedule = text;
|
|
592
|
+
fields.type = text ? 'scheduled' : 'one-time';
|
|
593
|
+
if (text !== (job.schedule || '')) changed.push('schedule');
|
|
594
|
+
}
|
|
595
|
+
if (requiresPr !== undefined) {
|
|
596
|
+
if (typeof requiresPr !== 'boolean') return { error: 'requires_pr must be true or false' };
|
|
597
|
+
// Always passed on, so a type change in the same call cannot swap it for
|
|
598
|
+
// the new type's default; only reported when it changes what the card does.
|
|
599
|
+
fields.requiresPr = requiresPr;
|
|
600
|
+
if (requiresPr !== jobRequiresPr(job)) changed.push('requires_pr');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!changed.length) {
|
|
604
|
+
return { error: 'Nothing to change — pass a new title, detail, repo, schedule or requires_pr.' };
|
|
605
|
+
}
|
|
606
|
+
const result = updateJob(job.id, fields, broadcast);
|
|
607
|
+
if (result.error) return result;
|
|
608
|
+
// Stamped before the repaint that carries it to every open board.
|
|
609
|
+
job.editedByAgent = session ? session.name : null;
|
|
610
|
+
job.editedAt = new Date().toISOString();
|
|
611
|
+
if (session && broadcast) {
|
|
612
|
+
broadcast({
|
|
613
|
+
type: 'notification', level: 'info',
|
|
614
|
+
message: `${session.name} edited a job: "${result.job.title}"`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return { job: jobSummary(result.job), changed };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The agent's own "I am done". A one-time card moves to Review on this call,
|
|
621
|
+
// with its agent and worktree kept. Only the agent the card is linked to may
|
|
622
|
+
// finish it, so an agent cannot close out someone else's work.
|
|
623
|
+
//
|
|
624
|
+
// A card that requires a PR must name one, and it must be the open PR on this
|
|
625
|
+
// card's own branch: the link lands on the card, so a made-up or unrelated URL
|
|
626
|
+
// would send the reviewer to the wrong place. A card that requires none must
|
|
627
|
+
// carry a summary, since that is the whole of what Review has to show.
|
|
628
|
+
export async function finishJobForAgent({ session, summary, prUrl }, broadcast, { findPr = findPrForBranch } = {}) {
|
|
629
|
+
const job = session ? allJobs().find(j => j.agentSessionId === session.id) : null;
|
|
630
|
+
if (!job) return { error: 'This agent is not working a job on the board, so there is nothing to finish.' };
|
|
631
|
+
if (summary != null && typeof summary !== 'string') return { error: 'summary must be a string' };
|
|
632
|
+
// Already in Review: the PR poll can find the PR /ship opened before the
|
|
633
|
+
// agent calls in, or someone moved the card by hand. The agent is right that
|
|
634
|
+
// it is done, so this is a success, and a summary it brings still lands.
|
|
635
|
+
if (job.state === 'review') {
|
|
636
|
+
const note = String(summary || '').trim().slice(0, MAX_DETAIL_LEN);
|
|
637
|
+
let changed = false;
|
|
638
|
+
if (note) { job.resultSummary = note; changed = true; }
|
|
639
|
+
// A PR link that is not the card's: the agent reworked and opened a new
|
|
640
|
+
// PR on the same branch. It is the card's PR of record if it is that
|
|
641
|
+
// branch's open PR, checked the same way as a first finish.
|
|
642
|
+
const norm = u => String(u).trim().replace(/\/+$/, '').toLowerCase();
|
|
643
|
+
if (jobRequiresPr(job) && prUrl && prUrl.trim() && (!job.prUrl || norm(prUrl) !== norm(job.prUrl))) {
|
|
644
|
+
const askedBranch = job.branchName;
|
|
645
|
+
const found = await findPr(job.repoPath, askedBranch);
|
|
646
|
+
if (found.pr && norm(found.pr.url) === norm(prUrl) && job.state === 'review' && job.branchName === askedBranch) {
|
|
647
|
+
job.prUrl = found.pr.url;
|
|
648
|
+
job.prNumber = found.pr.number;
|
|
649
|
+
job.prClosedSeenAt = null;
|
|
650
|
+
changed = true;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (changed) persist(broadcast);
|
|
654
|
+
return { job: jobSummary(job) };
|
|
655
|
+
}
|
|
656
|
+
if (job.state !== 'in-progress') {
|
|
657
|
+
return { error: `"${job.title}" is in ${STATE_LABELS[job.state] || job.state} already, so there is nothing to finish.` };
|
|
658
|
+
}
|
|
659
|
+
if (prUrl != null && typeof prUrl !== 'string') return { error: 'pr_url must be a string' };
|
|
660
|
+
const text = String(summary || '').trim().slice(0, MAX_DETAIL_LEN);
|
|
661
|
+
const requiresPr = jobRequiresPr(job);
|
|
662
|
+
// The same skill under each CLI's spelling, as the prompt names it.
|
|
663
|
+
const ship = jobAgent(job) === 'codex' ? '$ship' : '/ship';
|
|
664
|
+
let pr = null;
|
|
665
|
+
if (requiresPr) {
|
|
666
|
+
if (!prUrl || !prUrl.trim()) {
|
|
667
|
+
return { error: `This job requires a pull request — run ${ship}, wait for it to open the PR, then call finish_job with its URL as pr_url.` };
|
|
668
|
+
}
|
|
669
|
+
const askedBranch = job.branchName;
|
|
670
|
+
const found = await findPr(job.repoPath, askedBranch);
|
|
671
|
+
if (found.error) return { error: `Could not check the pull request — ${found.error}` };
|
|
672
|
+
// Re-validate after the network call, as every other PR path does.
|
|
673
|
+
// The PR poll can move the card to Review during this very lookup; that is
|
|
674
|
+
// the same finish, so it takes the already-in-Review success above.
|
|
675
|
+
if (allJobs().includes(job) && job.state === 'review' && job.branchName === askedBranch) {
|
|
676
|
+
return finishJobForAgent({ session, summary }, broadcast, { findPr });
|
|
677
|
+
}
|
|
678
|
+
if (!allJobs().includes(job) || job.state !== 'in-progress' || job.branchName !== askedBranch) {
|
|
679
|
+
return { error: `"${job.title}" moved while its pull request was being checked, so it was left as it is now.` };
|
|
680
|
+
}
|
|
681
|
+
if (!found.pr) return { error: `There is no open pull request on ${askedBranch} yet — run ${ship} first.` };
|
|
682
|
+
const norm = u => String(u).trim().replace(/\/+$/, '').toLowerCase();
|
|
683
|
+
if (norm(prUrl) !== norm(found.pr.url)) {
|
|
684
|
+
return { error: `${prUrl} is not this job's pull request — the open one on ${askedBranch} is ${found.pr.url}.` };
|
|
685
|
+
}
|
|
686
|
+
pr = found.pr;
|
|
687
|
+
} else if (!text) {
|
|
688
|
+
return { error: 'This job needs no pull request, so the summary is its result — pass what you did or found as summary.' };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
job.state = 'review';
|
|
692
|
+
job.reviewAt = new Date().toISOString();
|
|
693
|
+
job.resultSummary = text || null;
|
|
694
|
+
if (pr) {
|
|
695
|
+
job.prUrl = pr.url;
|
|
696
|
+
job.prNumber = pr.number;
|
|
697
|
+
}
|
|
698
|
+
job.lastError = null;
|
|
699
|
+
job.lastErrorAt = null;
|
|
700
|
+
clearPrCheckError(job);
|
|
701
|
+
persist(broadcast);
|
|
702
|
+
if (broadcast) {
|
|
703
|
+
broadcast({
|
|
704
|
+
type: 'notification', level: 'info',
|
|
705
|
+
message: `Job "${job.title}" moved to Review — ${pr ? `PR #${pr.number}` : `${session.name} finished`}`,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
notifyBillion(job);
|
|
709
|
+
return { job: jobSummary(job) };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// A card Billion posted tells Billion the moment it lands in Review, so a
|
|
713
|
+
// finished result is picked up now rather than at its next wake-up. Billion
|
|
714
|
+
// only: any other agent that posted a card may be mid-conversation with a
|
|
715
|
+
// person, and a notice typed into that terminal would be an interruption
|
|
716
|
+
// nobody asked for. The summary is a worker's text, so it goes in quoted and
|
|
717
|
+
// trimmed; read_job has the whole of it.
|
|
718
|
+
const NOTICE_SUMMARY_CHARS = 1500;
|
|
719
|
+
export function notifyBillion(job) {
|
|
720
|
+
if (!job.postedByBillion) return false;
|
|
721
|
+
const billion = liveBillion();
|
|
722
|
+
if (!billion) return false;
|
|
723
|
+
const summary = job.resultSummary || '';
|
|
724
|
+
const lines = [
|
|
725
|
+
job.prUrl ? `Pull request: ${job.prUrl}` : null,
|
|
726
|
+
summary ? `Summary: ${summary.length > NOTICE_SUMMARY_CHARS ? `${summary.slice(0, NOTICE_SUMMARY_CHARS)}… (read_job for the rest)` : summary}` : null,
|
|
727
|
+
].filter(Boolean);
|
|
728
|
+
return sendNotice(billion, `"${job.title}" (card ${job.id}, ${basename(job.repoPath || '')}) is in Review.`, lines);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Billion's verdict on one of its own cards in Review (docs/BILLION.md, part 3).
|
|
732
|
+
// Accept files it as Done and retires its agent; send it back returns it to To
|
|
733
|
+
// do with the reason added to its detail, so the next worker knows what to fix.
|
|
734
|
+
//
|
|
735
|
+
// Only Billion, only its own cards, only from Review: this is the owner's
|
|
736
|
+
// "Done" button handed to one agent, not to every agent on the board. A card
|
|
737
|
+
// with a pull request is not accepted here — merging it is what files it as
|
|
738
|
+
// Done, and a Done card whose PR never merged would claim work shipped that
|
|
739
|
+
// did not.
|
|
740
|
+
const SENT_BACK_CHARS = 2000;
|
|
741
|
+
export async function closeJobForAgent({ session, id, accept, note }, broadcast, { killSession } = {}) {
|
|
742
|
+
if (!session?.isBillion) return { error: 'Only Billion can close cards.' };
|
|
743
|
+
const job = allJobs().find(j => j.id === id);
|
|
744
|
+
if (!job) return { error: `No card with id "${id}". list_jobs shows the ids.` };
|
|
745
|
+
if (!job.postedByBillion) return { error: `"${job.title}" was not posted by you, so it is not yours to close.` };
|
|
746
|
+
if (job.state !== 'review') return { error: `"${job.title}" is in ${STATE_LABELS[job.state] || job.state}; only a card in Review can be closed.` };
|
|
747
|
+
const reason = typeof note === 'string' ? note.trim() : '';
|
|
748
|
+
if (accept) {
|
|
749
|
+
if (job.prUrl) return { error: `"${job.title}" has a pull request (${job.prUrl}). Merge it, or close it to drop the work: either way the board files the card away on its own.` };
|
|
750
|
+
const result = await moveJob(job.id, 'done', broadcast, { killSession });
|
|
751
|
+
return result.error ? result : { job: jobSummary(job), accepted: true };
|
|
752
|
+
}
|
|
753
|
+
if (!reason) return { error: 'Say why it goes back (note): the next worker only knows what the card tells it.' };
|
|
754
|
+
// The note goes on before the move: once the card is back in To do the
|
|
755
|
+
// dispatcher may hand it out, and the worker must get the reason with it.
|
|
756
|
+
// The PR is kept for the reply, since the move clears it from the card.
|
|
757
|
+
const oldPrUrl = job.prUrl || null;
|
|
758
|
+
const oldDetail = job.detail;
|
|
759
|
+
// The note always fits: it is the old detail that gives way.
|
|
760
|
+
// Capped well short of the card's limit, so the task itself always survives.
|
|
761
|
+
const sentBack = `Sent back by ${BILLION_NAME}: ${reason}`.slice(0, SENT_BACK_CHARS);
|
|
762
|
+
const room = MAX_DETAIL_LEN - sentBack.length - 2;
|
|
763
|
+
job.detail = job.detail && room > 0 ? `${job.detail.slice(0, room)}\n\n${sentBack}` : sentBack;
|
|
764
|
+
const result = await moveJob(job.id, 'todo', broadcast, { killSession });
|
|
765
|
+
if (result.error) {
|
|
766
|
+
job.detail = oldDetail;
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
return { job: jobSummary(job), accepted: false, oldPrUrl };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// A card stops being editable the moment it leaves To do, whoever is asking.
|
|
773
|
+
//
|
|
774
|
+
// The reasons stack up: its agent was handed the title, detail and attachment
|
|
775
|
+
// paths in its prompt at dispatch, so a later edit changes nothing about the
|
|
776
|
+
// run and leaves the card describing work nobody was asked to do; repointing
|
|
777
|
+
// repoPath misattributes work already under way in a worktree; and flipping an
|
|
778
|
+
// in-flight card to a schedule would strand its agent on a card that is never
|
|
779
|
+
// dispatched and never moves.
|
|
780
|
+
//
|
|
781
|
+
// The board's own form has only ever offered Edit on a To do card, so this is
|
|
782
|
+
// the rule the interface always implied — now enforced for every door into the
|
|
783
|
+
// store rather than trusted to the button not being drawn.
|
|
784
|
+
function editableInPlace(job) {
|
|
785
|
+
if (job.state === 'todo') return null;
|
|
786
|
+
const label = STATE_LABELS[job.state] || job.state;
|
|
787
|
+
return {
|
|
788
|
+
error: `"${job.title}" is in ${label}, and only cards still in To do can be edited — `
|
|
789
|
+
+ 'its agent has already been handed the card as it stands.',
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
export function updateJob(jobId, fields, broadcast) {
|
|
794
|
+
const job = allJobs().find(j => j.id === jobId);
|
|
795
|
+
if (!job) return { error: 'Job not found' };
|
|
796
|
+
const gate = editableInPlace(job);
|
|
797
|
+
if (gate) return gate;
|
|
798
|
+
// Everything else that can be refused is checked before anything on the job
|
|
799
|
+
// is touched, or an error reply would leave the card half-edited in memory
|
|
800
|
+
// for the next unrelated persist to write out.
|
|
801
|
+
const plan = planAttachments(job, fields.attachments);
|
|
802
|
+
if (plan?.error) return plan;
|
|
803
|
+
// Refused before anything is written, alongside the attachment plan and the
|
|
804
|
+
// type/schedule pair, for the same reason: an error reply must not leave a
|
|
805
|
+
// half-edited card in memory.
|
|
806
|
+
let mode = null;
|
|
807
|
+
if (fields.permissionMode !== undefined) {
|
|
808
|
+
mode = resolveJobPermissionMode(fields.permissionMode);
|
|
809
|
+
if (mode.error) return { error: mode.error };
|
|
810
|
+
}
|
|
811
|
+
let cli = null;
|
|
812
|
+
if (fields.agent !== undefined) {
|
|
813
|
+
cli = resolveJobAgent(fields.agent);
|
|
814
|
+
if (cli.error) return { error: cli.error };
|
|
815
|
+
}
|
|
816
|
+
// Type and schedule move together: "scheduled with no cron" and "one-time
|
|
817
|
+
// carrying a cron" are both incoherent, so they are resolved as a pair and
|
|
818
|
+
// rejected as a pair.
|
|
819
|
+
let resolved = null;
|
|
820
|
+
let changes = false;
|
|
821
|
+
if (fields.type !== undefined || fields.schedule !== undefined) {
|
|
822
|
+
resolved = resolveJobType({
|
|
823
|
+
type: fields.type !== undefined ? fields.type : jobType(job),
|
|
824
|
+
schedule: fields.schedule !== undefined ? fields.schedule : job.schedule,
|
|
825
|
+
});
|
|
826
|
+
if (resolved.error) return { error: resolved.error };
|
|
827
|
+
changes = resolved.type !== jobType(job)
|
|
828
|
+
|| (resolved.schedule || null) !== (job.schedule || null);
|
|
829
|
+
}
|
|
830
|
+
// The disk write is the last thing that can fail, and it happens before the
|
|
831
|
+
// first field changes.
|
|
832
|
+
const written = plan ? applyAttachments(job, plan) : null;
|
|
833
|
+
if (written?.error) return written;
|
|
834
|
+
if (written) job.attachments = written.attachments;
|
|
835
|
+
if (typeof fields.title === 'string' && fields.title.trim()) job.title = fields.title.trim().slice(0, MAX_TITLE_LEN);
|
|
836
|
+
if (typeof fields.detail === 'string') job.detail = fields.detail.trim().slice(0, MAX_DETAIL_LEN);
|
|
837
|
+
if (fields.repoPath) job.repoPath = fields.repoPath;
|
|
838
|
+
if (mode) job.permissionMode = mode.permissionMode;
|
|
839
|
+
if (cli) job.agent = cli.agent;
|
|
840
|
+
const typeChanged = !!resolved && resolved.type !== jobType(job);
|
|
841
|
+
if (resolved) {
|
|
842
|
+
job.type = resolved.type;
|
|
843
|
+
// A run turned into a schedule is no longer that schedule's run; left
|
|
844
|
+
// linked, it would sit in To do and hold its parent off for good.
|
|
845
|
+
if (typeChanged && resolved.type === 'scheduled') delete job.scheduleId;
|
|
846
|
+
job.schedule = resolved.schedule;
|
|
847
|
+
// Recompute only on a real change: the old due time belongs to the old
|
|
848
|
+
// cron, but a save that merely retitled the card must not re-arm an
|
|
849
|
+
// overdue schedule and eat the firing that was about to happen.
|
|
850
|
+
if (changes) job.nextRunAt = resolved.schedule ? nextCronIso(resolved.schedule) : null;
|
|
851
|
+
}
|
|
852
|
+
// A type change without an explicit choice takes the new type's default
|
|
853
|
+
// (see jobRequiresPr), so a schedule turned one-time does not silently keep
|
|
854
|
+
// a no-PR setting that only ever meant "its runs".
|
|
855
|
+
if (typeof fields.requiresPr === 'boolean') job.requiresPr = fields.requiresPr;
|
|
856
|
+
else if (typeChanged) job.requiresPr = defaultRequiresPr(job.type);
|
|
857
|
+
persist(broadcast);
|
|
858
|
+
return { job };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Pause / resume. Deliberately NOT routed through updateJob: that gate refuses
|
|
862
|
+
// any edit to a card past To do because the agent has already been handed the
|
|
863
|
+
// card's text, and pause changes no text — it only decides whether the NEXT
|
|
864
|
+
// firing goes out. So a schedule can be stopped while its current run is still
|
|
865
|
+
// going, which is exactly when someone reaches for it.
|
|
866
|
+
//
|
|
867
|
+
// Resuming re-arms from now rather than leaving a due time that went by during
|
|
868
|
+
// the pause: an overdue nextRunAt would dispatch on the very next scan, so a
|
|
869
|
+
// card resumed after a week off would fire immediately, which is the backlog
|
|
870
|
+
// replay the board avoids everywhere else.
|
|
871
|
+
export function setJobPaused(jobId, paused, broadcast) {
|
|
872
|
+
const job = allJobs().find(j => j.id === jobId);
|
|
873
|
+
if (!job) return { error: 'Job not found' };
|
|
874
|
+
const next = !!paused;
|
|
875
|
+
if (job.paused === next) return { job };
|
|
876
|
+
job.paused = next;
|
|
877
|
+
if (!next && job.schedule) job.nextRunAt = nextCronIso(job.schedule);
|
|
878
|
+
persist(broadcast);
|
|
879
|
+
return { job };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Deleting a card retires its agent, for the same reason requeueing does:
|
|
883
|
+
// otherwise the agent keeps running with nothing pointing at it, stops counting
|
|
884
|
+
// toward the per-repo cap, and is never cleaned up. removeWorktree still
|
|
885
|
+
// protects the work — dirty or unpushed changes become an orphan.
|
|
886
|
+
export async function deleteJob(jobId, broadcast, { killSession } = {}) {
|
|
887
|
+
const jobs = allJobs();
|
|
888
|
+
const idx = jobs.findIndex(j => j.id === jobId);
|
|
889
|
+
if (idx === -1) return { error: 'Job not found' };
|
|
890
|
+
const [removed] = jobs.splice(idx, 1);
|
|
891
|
+
persist(broadcast);
|
|
892
|
+
// After persist, so a file the OS will not let go of (open in a browser tab
|
|
893
|
+
// on Windows) cannot leave a card that is gone from memory but back on the
|
|
894
|
+
// next restart. The id is checked the same way a path is: it too is
|
|
895
|
+
// config.json text.
|
|
896
|
+
const dir = attachmentDir(removed.id);
|
|
897
|
+
if (insideAttachments(dir)) {
|
|
898
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch (err) {
|
|
899
|
+
console.error(`Failed to remove attachments for deleted job "${removed.title}":`, err.message);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// With the card gone, nothing else would ever retire its agent. An exited
|
|
903
|
+
// one too — its session entry and worktree are just as stranded, and
|
|
904
|
+
// killSession copes with a dead process.
|
|
905
|
+
const sessionId = removed.agentSessionId;
|
|
906
|
+
if (sessionId && killSession && sessions.get(sessionId)) {
|
|
907
|
+
try {
|
|
908
|
+
await killSession(sessionId);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
console.error(`Failed to close agent for deleted job "${removed.title}":`, err.message);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return { job: removed };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Manual move (the buttons on a card). Permissive everywhere the card is still
|
|
917
|
+
// on the board, because automatic transitions can be wrong and the user should
|
|
918
|
+
// have the last word — with one exception, below: done is terminal.
|
|
919
|
+
//
|
|
920
|
+
// Moving back to To do also RETIRES the job's agent. Unlinking it without
|
|
921
|
+
// killing it left a running agent that no job pointed at: it no longer counted
|
|
922
|
+
// toward the per-repo cap, so the board would dispatch a replacement alongside
|
|
923
|
+
// it, and repeating the move walks straight past the cap. removeWorktree still
|
|
924
|
+
// protects the work: uncommitted or unpushed changes become an orphan rather
|
|
925
|
+
// than being deleted.
|
|
926
|
+
// discardChanges: only for a caller that knows the worktree is scratch (a
|
|
927
|
+
// superseded run, whose result is its summary); unpushed commits stay protected.
|
|
928
|
+
export async function moveJob(jobId, state, broadcast, { killSession, findPr = findPrForBranch, discardChanges = false } = {}) {
|
|
929
|
+
if (!JOB_STATES.includes(state)) return { error: `Unknown state "${state}"` };
|
|
930
|
+
const job = allJobs().find(j => j.id === jobId);
|
|
931
|
+
if (!job) return { error: 'Job not found' };
|
|
932
|
+
// Done is terminal. A finished card is the record of work that shipped, and
|
|
933
|
+
// the only thing that can happen to it is deletion.
|
|
934
|
+
//
|
|
935
|
+
// It is enforced here and not just in the UI because letting a card back onto
|
|
936
|
+
// the board could not be made safe. The card keeps the PR that finished it,
|
|
937
|
+
// and reviewAt is the sweep's time floor, so a job walked back to In progress
|
|
938
|
+
// carried a spent PR of record into its new attempt: checkMergedPullRequests
|
|
939
|
+
// re-matched that same old merge on the very next scan and filed the card
|
|
940
|
+
// away again — killing whatever agent had been re-adopted on the branch,
|
|
941
|
+
// because finishing retires one. Clearing those fields would
|
|
942
|
+
// just trade that for a card whose history is gone. Work that follows a
|
|
943
|
+
// merged PR is a new job, and the archive keeps the old one to point at.
|
|
944
|
+
if (job.state === 'done') {
|
|
945
|
+
return { error: 'That job is finished — post a new job for follow-up work, or delete this card' };
|
|
946
|
+
}
|
|
947
|
+
// A schedule is never dispatched, so it has nowhere to move: its runs are
|
|
948
|
+
// the cards that cross the board. The UI offers it no moves; the raw
|
|
949
|
+
// message has to be refused too.
|
|
950
|
+
if (isScheduled(job)) {
|
|
951
|
+
return { error: 'A schedule stays in To do — its runs are the cards that move. Pause or delete it instead.' };
|
|
952
|
+
}
|
|
953
|
+
// A job with no branch has never been dispatched, so nothing can move it out
|
|
954
|
+
// of in-progress again: the dispatcher only looks at todo, and the PR watcher
|
|
955
|
+
// only at jobs with a branch. Accepting the move would strand it forever.
|
|
956
|
+
if (state === 'in-progress' && !job.branchName) {
|
|
957
|
+
return { error: 'That job has never been dispatched — leave it in To do so the board can pick it up' };
|
|
958
|
+
}
|
|
959
|
+
// Two moves retire the agent: back to To do, and to Done. Review keeps it —
|
|
960
|
+
// the work is finished, the agent idles at its prompt, and it is right there
|
|
961
|
+
// if the review turns up something to fix. The per-repo cap only counts In
|
|
962
|
+
// progress, so a kept Review agent does not hold a slot.
|
|
963
|
+
//
|
|
964
|
+
// To do retires it because the next dispatch spawns a fresh one. Done retires
|
|
965
|
+
// it, worktree and all, because a finished card is off the board and nothing
|
|
966
|
+
// visible would point at an agent left running.
|
|
967
|
+
const retiringSessionId = state === 'todo' || state === 'done' ? job.agentSessionId : null;
|
|
968
|
+
// Captured before To do clears them, for the orphan release at the end.
|
|
969
|
+
const attempt = { branchName: job.branchName, repoPath: job.repoPath, worktreePath: job.worktreePath };
|
|
970
|
+
const fromState = job.state;
|
|
971
|
+
job.state = state;
|
|
972
|
+
if (state === 'todo') {
|
|
973
|
+
job.agentSessionId = null;
|
|
974
|
+
job.agentName = null;
|
|
975
|
+
job.startedAt = null;
|
|
976
|
+
job.branchName = null;
|
|
977
|
+
job.worktreePath = null;
|
|
978
|
+
job.prUrl = null;
|
|
979
|
+
job.prNumber = null;
|
|
980
|
+
job.reviewAt = null;
|
|
981
|
+
job.resultSummary = null; // the last attempt's result, not this one's
|
|
982
|
+
}
|
|
983
|
+
// Restamped when the work comes back from In progress: a run sent back for a
|
|
984
|
+
// follow-up is the newest result again (see supersededRuns).
|
|
985
|
+
// Only on a card that needs no PR: on one that does, reviewAt is also the
|
|
986
|
+
// merge sweep's time floor, which must not move past a merge.
|
|
987
|
+
if (state === 'review' && (!job.reviewAt || (fromState === 'in-progress' && !jobRequiresPr(job)))) job.reviewAt = new Date().toISOString();
|
|
988
|
+
// Stamped on arrival, and never cleared, because nothing leaves done. A job
|
|
989
|
+
// finished by hand gets a doneAt but no prMergedAt: the board is recording
|
|
990
|
+
// that the USER called it finished, which is not a claim about GitHub.
|
|
991
|
+
if (state === 'done') {
|
|
992
|
+
job.doneAt = new Date().toISOString();
|
|
993
|
+
clearAttachments(job);
|
|
994
|
+
}
|
|
995
|
+
// The note explaining why the board could not check this job's PR describes an
|
|
996
|
+
// attempt the user is now overriding by hand, so it must not survive the move:
|
|
997
|
+
// not onto a fresh To do card, where it would report a failure against work
|
|
998
|
+
// that has not been tried yet, and not into the archive, where it would still
|
|
999
|
+
// be telling the user to move the card by hand. The Review case is handled
|
|
1000
|
+
// below, where a successful lookup clears it too.
|
|
1001
|
+
if (state !== 'review') {
|
|
1002
|
+
clearPrCheckError(job);
|
|
1003
|
+
job.prClosedSeenAt = null; // a closed reading is about this stay in Review only
|
|
1004
|
+
}
|
|
1005
|
+
// A manual move means "the PR was opened outside the board". Look it up, or
|
|
1006
|
+
// the card sits in Review with no link to the thing it produced — nothing
|
|
1007
|
+
// else backfills it, since the watcher only examines in-progress jobs.
|
|
1008
|
+
if (state === 'review' && jobRequiresPr(job) && !job.prNumber && job.branchName) {
|
|
1009
|
+
// Best-effort: a lookup that fails must not abandon the move half-applied,
|
|
1010
|
+
// with the state changed in memory but never persisted and the agent never
|
|
1011
|
+
// retired. The card just goes without its link, as it did before.
|
|
1012
|
+
try {
|
|
1013
|
+
const { pr } = await findPr(job.repoPath, job.branchName);
|
|
1014
|
+
// Re-check before writing: findPr is a network call, and a concurrent
|
|
1015
|
+
// requeue during it would otherwise leave a To do card carrying PR links
|
|
1016
|
+
// for an attempt that no longer exists.
|
|
1017
|
+
if (pr && job.state === 'review' && allJobs().includes(job)) {
|
|
1018
|
+
job.prUrl = pr.url;
|
|
1019
|
+
job.prNumber = pr.number;
|
|
1020
|
+
job.lastError = null;
|
|
1021
|
+
job.lastErrorAt = null;
|
|
1022
|
+
// The PR is in hand, so any earlier "cannot check" note is obsolete.
|
|
1023
|
+
job.prCheckError = null;
|
|
1024
|
+
job.prCheckErrorAt = null;
|
|
1025
|
+
}
|
|
1026
|
+
} catch (err) {
|
|
1027
|
+
console.error(`PR lookup failed while moving "${job.title}" to review:`, err.message);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Persist and repaint before the kill so the card moves immediately; the kill
|
|
1031
|
+
// then emits its own session-ended and orphan notifications.
|
|
1032
|
+
persist(broadcast);
|
|
1033
|
+
if (retiringSessionId && killSession) {
|
|
1034
|
+
const session = sessions.get(retiringSessionId);
|
|
1035
|
+
if (session && !session.exited) {
|
|
1036
|
+
try {
|
|
1037
|
+
await killSession(retiringSessionId, { discardChanges });
|
|
1038
|
+
// Only after it succeeded: a failed kill leaves the agent running, and
|
|
1039
|
+
// the card must keep pointing at it rather than become unreachable.
|
|
1040
|
+
if (job.agentSessionId === retiringSessionId) {
|
|
1041
|
+
job.agentSessionId = null;
|
|
1042
|
+
persist(broadcast);
|
|
1043
|
+
}
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
console.error(`Failed to close agent for job "${job.title}":`, err.message);
|
|
1046
|
+
}
|
|
1047
|
+
} else if (job.agentSessionId === retiringSessionId) {
|
|
1048
|
+
// Nothing to kill — the session already went. Drop the link anyway, or
|
|
1049
|
+
// the card keeps a dead id, which is how a stale link outlived a restart
|
|
1050
|
+
// and resolved to an unrelated agent in the next process generation.
|
|
1051
|
+
job.agentSessionId = null;
|
|
1052
|
+
persist(broadcast);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (state === 'done' || state === 'todo') await releaseOrphanedWorktree(attempt, broadcast, { discardChanges });
|
|
1056
|
+
return { job };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// A restart kills every agent, and a kept agent's worktree comes back as an
|
|
1060
|
+
// orphan record rather than a session. A card that reaches Done (or goes back
|
|
1061
|
+
// to To do) before anyone re-adopts it has no agent to retire, so the worktree
|
|
1062
|
+
// is released through that orphan instead — the same removeWorktree killSession uses, so
|
|
1063
|
+
// unpushed or dirty work still stays behind as the orphan.
|
|
1064
|
+
async function releaseOrphanedWorktree({ branchName, repoPath, worktreePath }, broadcast, { discardChanges = false } = {}) {
|
|
1065
|
+
if (!branchName) return false;
|
|
1066
|
+
// By worktree path when the card recorded one: a schedule's runs share a
|
|
1067
|
+
// title, so a later run can reuse an earlier one's branch name.
|
|
1068
|
+
const entry = [...orphans.values()].find(o => o.repoPath === repoPath
|
|
1069
|
+
&& (worktreePath ? o.worktreePath === worktreePath : o.branchName === branchName));
|
|
1070
|
+
if (!entry || adoptingOrphans.has(entry.id)) return false;
|
|
1071
|
+
// Held for the whole removal, which is several awaited git calls: a re-adopt
|
|
1072
|
+
// clicked in that window would spawn an agent in a worktree about to go.
|
|
1073
|
+
adoptingOrphans.add(entry.id);
|
|
1074
|
+
let orphaned;
|
|
1075
|
+
try {
|
|
1076
|
+
({ orphaned } = await removeWorktree(entry, { discardChanges }));
|
|
1077
|
+
} finally {
|
|
1078
|
+
adoptingOrphans.delete(entry.id);
|
|
1079
|
+
}
|
|
1080
|
+
if (orphaned) return false;
|
|
1081
|
+
codenamePool.recycle(entry.name);
|
|
1082
|
+
if (entry.worktreePath) codenamePool.recycle(basename(entry.worktreePath)); // differs after a rename
|
|
1083
|
+
orphans.delete(entry.id);
|
|
1084
|
+
syncOrphansToConfig(broadcast);
|
|
1085
|
+
if (broadcast) broadcast({ type: 'orphans-list', orphans: [...orphans.values()] });
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
export function updateSettings(fields, broadcast) {
|
|
1090
|
+
const settings = boardSettings();
|
|
1091
|
+
if (typeof fields.running === 'boolean') settings.running = fields.running;
|
|
1092
|
+
if (Number.isFinite(fields.maxPerRepo)) settings.maxPerRepo = Math.max(1, Math.min(10, Math.floor(fields.maxPerRepo)));
|
|
1093
|
+
if (Number.isFinite(fields.intervalMs)) settings.intervalMs = Math.max(30_000, Math.min(60 * 60_000, Math.floor(fields.intervalMs)));
|
|
1094
|
+
// Allowlisted: this value is interpolated into the agent's command line.
|
|
1095
|
+
// A valid one is also the only thing that ever sets permissionModeChosen —
|
|
1096
|
+
// that flag is what tells the stored mode apart from one nobody picked.
|
|
1097
|
+
if (isValidPermissionMode(fields.permissionMode)) {
|
|
1098
|
+
settings.permissionMode = fields.permissionMode;
|
|
1099
|
+
settings.permissionModeChosen = true;
|
|
1100
|
+
}
|
|
1101
|
+
persist(broadcast);
|
|
1102
|
+
return { settings };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// --- Dispatch ---
|
|
1106
|
+
|
|
1107
|
+
function liveSessionIds() {
|
|
1108
|
+
const live = new Set();
|
|
1109
|
+
for (const [id, s] of sessions) if (!s.exited) live.add(id);
|
|
1110
|
+
return live;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Repos we can actually spawn into right now. A repo removed from the sidebar
|
|
1114
|
+
// (or whose directory has gone missing) leaves its jobs queued rather than
|
|
1115
|
+
// failing them — the path may well come back.
|
|
1116
|
+
function availableRepos() {
|
|
1117
|
+
const set = new Set();
|
|
1118
|
+
for (const repo of config.repos) if (existsSync(repo.path)) set.add(repo.path);
|
|
1119
|
+
return set;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// `onSessionCreated` publishes the new session to clients. It is injected
|
|
1123
|
+
// rather than imported: ws.js already imports this module, so reaching back for
|
|
1124
|
+
// sessionPayload() here would close a cycle. Without it a board agent would run
|
|
1125
|
+
// invisibly — a PTY with no tab, which is precisely the thing the user must be
|
|
1126
|
+
// able to reach to answer a question.
|
|
1127
|
+
export async function dispatchOnce(createSession, broadcast, { onSessionCreated, killSession } = {}) {
|
|
1128
|
+
const settings = boardSettings();
|
|
1129
|
+
const candidates = selectDispatchableJobs(allJobs(), {
|
|
1130
|
+
maxPerRepo: settings.maxPerRepo,
|
|
1131
|
+
availableRepos: availableRepos(),
|
|
1132
|
+
liveSessionIds: liveSessionIds(),
|
|
1133
|
+
});
|
|
1134
|
+
const dispatched = [];
|
|
1135
|
+
for (const job of candidates) {
|
|
1136
|
+
const command = buildJobCommand(job, { permissionMode: boardModeFor(jobAgent(job)) });
|
|
1137
|
+
// Kept so the recheck below can tell whether the card still dispatches
|
|
1138
|
+
// into the same repo as the session it is about to be handed.
|
|
1139
|
+
const spawnedRepo = job.repoPath;
|
|
1140
|
+
// Branch named after the job, not a cocktail, so `git branch` reads like
|
|
1141
|
+
// the board. Two jobs can share a title, so collisions take a -2 suffix
|
|
1142
|
+
// rather than failing the dispatch.
|
|
1143
|
+
const branch = branchSlugFromTitle(job.title);
|
|
1144
|
+
// spawnedBy:'board' rides along on the session so the client can open the
|
|
1145
|
+
// tab WITHOUT stealing focus. An unattended dispatcher yanking the user out
|
|
1146
|
+
// of whatever they are typing every few minutes would be unusable.
|
|
1147
|
+
const result = await createSession(command, null, job.repoPath, branch, job.postedBy || null, {
|
|
1148
|
+
spawnedBy: 'board', jobId: job.id, branchSuffixOnCollision: true,
|
|
1149
|
+
// Billion's cards ask Billion before they ask a person (part 4).
|
|
1150
|
+
approvalsToBillion: job.postedByBillion === true,
|
|
1151
|
+
});
|
|
1152
|
+
if (result.error) {
|
|
1153
|
+
// Surface the failure on the card and leave it in To do; the next tick
|
|
1154
|
+
// retries. A broken repo shows a visible reason instead of a job that
|
|
1155
|
+
// silently never starts.
|
|
1156
|
+
job.lastError = result.error;
|
|
1157
|
+
job.lastErrorAt = new Date().toISOString();
|
|
1158
|
+
if (broadcast) broadcast({ type: 'notification', level: 'error', message: `Job "${job.title}" could not start: ${result.error}` });
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
const session = result.session;
|
|
1162
|
+
|
|
1163
|
+
// Re-validate before claiming the job. createSession takes seconds (addRepo
|
|
1164
|
+
// + `git worktree add` + a PTY spawn) and WebSocket handlers run during it,
|
|
1165
|
+
// so the job may have been deleted, requeued or moved while we waited.
|
|
1166
|
+
// Writing state onto a job that is gone would leave the agent running with
|
|
1167
|
+
// no card pointing at it: invisible to the board, uncounted by the cap, and
|
|
1168
|
+
// never cleaned up. The scan guard does not cover this — it serialises
|
|
1169
|
+
// scans against each other, not against the user.
|
|
1170
|
+
//
|
|
1171
|
+
// The argv is rechecked on the same terms — rebuilt from the card as it
|
|
1172
|
+
// is now (boardSettings() re-read, so a board retuned mid-tick counts too)
|
|
1173
|
+
// and compared with the one actually spawned. That covers everything the
|
|
1174
|
+
// argv is made of at once: permission mode, which CLI, the prompt (title,
|
|
1175
|
+
// detail, attachments) — so a card tightened, switched or rewritten while
|
|
1176
|
+
// its agent spawned cannot be claimed by a process running the old text
|
|
1177
|
+
// under a mode neither it nor the board still says. The repo is checked
|
|
1178
|
+
// the same way, since it is not in the argv but is where the worktree
|
|
1179
|
+
// was just made. Abandoning the spawn hands it the same remedy every
|
|
1180
|
+
// other change in this window gets: the card stays in To do and the next
|
|
1181
|
+
// tick dispatches it again, as it now reads.
|
|
1182
|
+
// A card turned into a schedule during the spawn builds the same argv (the
|
|
1183
|
+
// prompt no longer depends on the type), so the type is checked outright:
|
|
1184
|
+
// a schedule claimed as In progress could never move again.
|
|
1185
|
+
const stillQueued = allJobs().includes(job) && job.state === 'todo' && !isScheduled(job)
|
|
1186
|
+
&& buildJobCommand(job, { permissionMode: boardModeFor(jobAgent(job)) }) === command
|
|
1187
|
+
&& job.repoPath === spawnedRepo;
|
|
1188
|
+
if (!stillQueued) {
|
|
1189
|
+
if (killSession) {
|
|
1190
|
+
try { await killSession(session.id); } catch (err) {
|
|
1191
|
+
console.error(`Failed to clean up agent for vanished job "${job.title}":`, err.message);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (onSessionCreated) onSessionCreated(session);
|
|
1198
|
+
job.state = 'in-progress';
|
|
1199
|
+
job.agentSessionId = session.id;
|
|
1200
|
+
job.agentName = session.name;
|
|
1201
|
+
job.startedAt = new Date().toISOString();
|
|
1202
|
+
job.branchName = session.branchName;
|
|
1203
|
+
job.worktreePath = session.worktreePath;
|
|
1204
|
+
job.lastError = null;
|
|
1205
|
+
job.lastErrorAt = null;
|
|
1206
|
+
|
|
1207
|
+
dispatched.push({ job, session });
|
|
1208
|
+
}
|
|
1209
|
+
if (dispatched.length > 0 || candidates.length > 0) persist(broadcast);
|
|
1210
|
+
return dispatched;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Reconnect a re-adopted orphan to the job it was working on.
|
|
1214
|
+
//
|
|
1215
|
+
// A server restart clears every job's agentSessionId, because those sessions
|
|
1216
|
+
// are gone. Re-adopting the orphan brings the agent back, but nothing tied it
|
|
1217
|
+
// to its card: the job stayed "agent gone" while the agent was demonstrably
|
|
1218
|
+
// alive and working, it never counted toward the per-repo cap again (so the
|
|
1219
|
+
// board would dispatch a replacement alongside it), and when its PR appeared
|
|
1220
|
+
// the board could not retire it or release its worktree.
|
|
1221
|
+
//
|
|
1222
|
+
// The branch is the link. It is created per job, never reused while it exists,
|
|
1223
|
+
// and survives the restart on both the orphan record and the job — the one
|
|
1224
|
+
// identifier that outlives the session.
|
|
1225
|
+
//
|
|
1226
|
+
// findJobForBranch is that link on its own: the card an agent on this branch
|
|
1227
|
+
// belongs to, or null. relinkSessionToJob below ties the session to it, and
|
|
1228
|
+
// resumeCommandForOrphan reads the card's agent through the same lookup before
|
|
1229
|
+
// the spawn, so the two cannot disagree about which card the orphan came from.
|
|
1230
|
+
export function findJobForBranch({ repoPath, branchName }) {
|
|
1231
|
+
if (!branchName) return null;
|
|
1232
|
+
// in-progress OR review: a job can reach review while its link is null (the
|
|
1233
|
+
// PR was found after a restart, so there was no session to retire), and the
|
|
1234
|
+
// agent re-adopted afterwards still belongs to that card.
|
|
1235
|
+
const matches = allJobs().filter(j =>
|
|
1236
|
+
j.branchName === branchName
|
|
1237
|
+
&& j.repoPath === repoPath
|
|
1238
|
+
&& (j.state === 'in-progress' || j.state === 'review')
|
|
1239
|
+
// Never steal a job that already has a live agent. A link to a session
|
|
1240
|
+
// that is gone or exited is not one: an agent closed by hand or crashed
|
|
1241
|
+
// leaves its id behind, and re-adopting it must still reach the card.
|
|
1242
|
+
&& (!j.agentSessionId || !sessions.get(j.agentSessionId) || sessions.get(j.agentSessionId).exited),
|
|
1243
|
+
);
|
|
1244
|
+
// Prefer work still in flight: if an old review job and a new in-progress job
|
|
1245
|
+
// share a branch, the agent belongs to the one that is not finished.
|
|
1246
|
+
return matches.find(j => j.state === 'in-progress') || matches[0] || null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// What re-adopting an orphan should run. The orphan record says which CLI the
|
|
1250
|
+
// session ran when it was orphaned by a restart or a close. One written before
|
|
1251
|
+
// that was noted, or discovered from a bare worktree on disk, carries no such
|
|
1252
|
+
// note, so its job card, matched on the branch, is the next witness; a manually
|
|
1253
|
+
// spawned agent has no card either, so the transcripts the CLIs leave under
|
|
1254
|
+
// their own homes are the last. With none of those it is Claude Code, the
|
|
1255
|
+
// board's own default — the same guess as before the note existed.
|
|
1256
|
+
//
|
|
1257
|
+
// When the card is known its permission mode rides along too: the resumed
|
|
1258
|
+
// session should run under the sandbox it was dispatched with, not the CLI's
|
|
1259
|
+
// default. A hand-spawned orphan has no card; its record's `permissionFlags`
|
|
1260
|
+
// (what it was started with) are resolved and returned as `flags`, and go on
|
|
1261
|
+
// the command when no card supplies a mode. The returned `agent` is what was
|
|
1262
|
+
// resolved (null when nothing was).
|
|
1263
|
+
//
|
|
1264
|
+
// A Codex agent's command names its session: the newest one recorded in
|
|
1265
|
+
// exactly its worktree (`codex resume <id>`), or Codex's picker when there is
|
|
1266
|
+
// none — never `--last`, which would reach a sibling worktree's session.
|
|
1267
|
+
//
|
|
1268
|
+
// `homes` is for tests, which must not probe the developer's real ~/.codex.
|
|
1269
|
+
export function orphanResumePlan(orphan, homes) {
|
|
1270
|
+
const card = findJobForBranch(orphan);
|
|
1271
|
+
// The note is trusted only when it is one of ours: config.json is hand-
|
|
1272
|
+
// editable, and a stray value would otherwise be stamped onto the new
|
|
1273
|
+
// session as if it had been seen.
|
|
1274
|
+
const known = (isValidJobAgent(orphan.agent) ? orphan.agent : null)
|
|
1275
|
+
|| (card ? jobAgent(card) : null);
|
|
1276
|
+
const probed = known ? null : transcriptsFor(orphan.worktreePath, homes);
|
|
1277
|
+
const agent = known || probed.agent;
|
|
1278
|
+
// A board agent whose card is gone (done, or deleted) still belongs to the
|
|
1279
|
+
// board: it resumes under the board's current mode, not the CLI's default —
|
|
1280
|
+
// and not the mode it was dispatched with, which the board may have
|
|
1281
|
+
// tightened since.
|
|
1282
|
+
const mode = card ? dispatchPermissionMode(card, boardModeFor(jobAgent(card)))
|
|
1283
|
+
: orphan.origin === 'board' ? dispatchPermissionMode(null, boardModeFor(agent))
|
|
1284
|
+
: null;
|
|
1285
|
+
// The flags it was spawned with, for a hand-spawned agent with no card.
|
|
1286
|
+
// They belong to the CLI on the record: a note-less orphan resolved by a
|
|
1287
|
+
// transcript has none to pass on, and resumes under that CLI's default.
|
|
1288
|
+
// Never the .env default: an agent spawned since it was set recorded the
|
|
1289
|
+
// flags it got, and one that recorded none may have named a mode the
|
|
1290
|
+
// allowlist doesn't read, which a default must not replace.
|
|
1291
|
+
const flags = recordedPermissionFlags(orphan);
|
|
1292
|
+
// A Codex agent is pinned to its own worktree's session by id — the one
|
|
1293
|
+
// the transcript probe already found, when that is how its CLI was known.
|
|
1294
|
+
const sessionId = agent !== 'codex' ? null
|
|
1295
|
+
: probed ? probed.codexSessionId
|
|
1296
|
+
: codexSessionIdFor(orphan.worktreePath, homes);
|
|
1297
|
+
return { agent, mode, flags, command: resumeCommand(agent, mode, flags, sessionId) };
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export function resumeCommandForOrphan(orphan, homes) {
|
|
1301
|
+
return orphanResumePlan(orphan, homes).command;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
export function relinkSessionToJob(session, broadcast) {
|
|
1305
|
+
if (!session) return null;
|
|
1306
|
+
const job = findJobForBranch(session);
|
|
1307
|
+
if (!job) return null;
|
|
1308
|
+
job.agentSessionId = session.id;
|
|
1309
|
+
job.agentName = session.name;
|
|
1310
|
+
job.lastError = null;
|
|
1311
|
+
job.lastErrorAt = null;
|
|
1312
|
+
job.prCheckError = null;
|
|
1313
|
+
job.prCheckErrorAt = null;
|
|
1314
|
+
session.jobId = job.id; // so the PR path can retire it like any board agent
|
|
1315
|
+
persist(broadcast);
|
|
1316
|
+
return job;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// --- PR watching ---
|
|
1320
|
+
|
|
1321
|
+
// --- gh plumbing ---
|
|
1322
|
+
|
|
1323
|
+
// One `gh` invocation. `token` picks the account; undefined means "whatever gh
|
|
1324
|
+
// is signed in as". GITHUB_TOKEN is dropped when we override, since it outranks
|
|
1325
|
+
// GH_TOKEN and would silently win.
|
|
1326
|
+
function runGh(args, { cwd, token, timeout = 15_000 } = {}) {
|
|
1327
|
+
const env = { ...process.env };
|
|
1328
|
+
if (token) {
|
|
1329
|
+
env.GH_TOKEN = token;
|
|
1330
|
+
delete env.GITHUB_TOKEN;
|
|
1331
|
+
}
|
|
1332
|
+
return new Promise((resolve, reject) => {
|
|
1333
|
+
execFile('gh', args, { cwd, env, timeout, maxBuffer: 1024 * 1024 },
|
|
1334
|
+
(err, out, stderr) => (err ? reject(Object.assign(err, { stderr })) : resolve(out)));
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Every account gh is signed in to, active one first.
|
|
1339
|
+
async function ghAccounts() {
|
|
1340
|
+
try {
|
|
1341
|
+
const parsed = JSON.parse(await runGh(['auth', 'status', '--json', 'hosts'], { timeout: 10_000 }));
|
|
1342
|
+
const found = [];
|
|
1343
|
+
for (const entries of Object.values(parsed.hosts || {})) {
|
|
1344
|
+
for (const entry of entries || []) {
|
|
1345
|
+
if (entry && entry.state === 'success' && entry.login) {
|
|
1346
|
+
found.push({ login: entry.login, active: !!entry.active });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
found.sort((a, b) => Number(b.active) - Number(a.active));
|
|
1351
|
+
return found.map(a => a.login);
|
|
1352
|
+
} catch (_) {
|
|
1353
|
+
// Older gh has no --json on auth status; fall back to the text form.
|
|
1354
|
+
try {
|
|
1355
|
+
const text = await runGh(['auth', 'status'], { timeout: 10_000 });
|
|
1356
|
+
return [...text.matchAll(/account\s+(\S+)/g)].map(m => m[1]);
|
|
1357
|
+
} catch (_) { return []; }
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
async function ghToken(login) {
|
|
1362
|
+
try {
|
|
1363
|
+
const token = (await runGh(['auth', 'token', '-u', login], { timeout: 10_000 })).trim();
|
|
1364
|
+
return token || null;
|
|
1365
|
+
} catch (_) { return null; }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Which account last answered for a repo, so the steady state stays one gh call
|
|
1369
|
+
// instead of a walk. Memory only; a stale guess costs one retry.
|
|
1370
|
+
const ghAccountForRepo = new Map();
|
|
1371
|
+
|
|
1372
|
+
// The account list and its tokens are the same for every job in a scan, but the
|
|
1373
|
+
// walk is per job — so without this a scan over N jobs pays N `gh auth status`
|
|
1374
|
+
// plus N-per-account `gh auth token` subprocess spawns for answers that cannot
|
|
1375
|
+
// have changed. Short TTL rather than a per-scan cache because both sweeps and
|
|
1376
|
+
// the "Run now" button walk independently; a minute is long enough to collapse
|
|
1377
|
+
// a scan and short enough that signing in or out is picked up promptly.
|
|
1378
|
+
const GH_AUTH_TTL_MS = 60_000;
|
|
1379
|
+
const ghAuthCache = new Map(); // key -> { at, value }
|
|
1380
|
+
|
|
1381
|
+
async function ghCached(key, produce) {
|
|
1382
|
+
const hit = ghAuthCache.get(key);
|
|
1383
|
+
if (hit && Date.now() - hit.at < GH_AUTH_TTL_MS) return hit.value;
|
|
1384
|
+
const value = await produce();
|
|
1385
|
+
ghAuthCache.set(key, { at: Date.now(), value });
|
|
1386
|
+
return value;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const ghAccountsCached = () => ghCached('accounts', ghAccounts);
|
|
1390
|
+
const ghTokenCached = (login) => ghCached(`token:${login}`, () => ghToken(login));
|
|
1391
|
+
|
|
1392
|
+
// Names the command in a failure message, so "…failed (tried 2 accounts)" says
|
|
1393
|
+
// what was actually run rather than leaving the reader to guess.
|
|
1394
|
+
const GH_PR_LIST = 'gh pr list';
|
|
1395
|
+
|
|
1396
|
+
// First line only: gh errors are one useful line plus usage noise.
|
|
1397
|
+
function ghErrorDetail(err) {
|
|
1398
|
+
return String(err.stderr || err.message || '').trim().split('\n')[0].slice(0, 200);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
async function prListOnce(repoPath, branchName, token) {
|
|
1402
|
+
try {
|
|
1403
|
+
const stdout = await runGh(openPrListArgs(branchName), { cwd: repoPath, token });
|
|
1404
|
+
return { pr: parsePrList(stdout) };
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
return { error: ghErrorDetail(err) || `${GH_PR_LIST} failed` };
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// The merged twin of prListOnce. `--state merged` rather than reading the state
|
|
1411
|
+
// off the open query, because gh's default listing is open-only: a merged PR
|
|
1412
|
+
// simply vanishes from it, which is indistinguishable from a branch that never
|
|
1413
|
+
// had one.
|
|
1414
|
+
async function mergedPrListOnce(repoPath, branchName, token, match) {
|
|
1415
|
+
try {
|
|
1416
|
+
const stdout = await runGh(mergedPrListArgs(branchName), { cwd: repoPath, token });
|
|
1417
|
+
return { pr: parseMergedPr(stdout, match) };
|
|
1418
|
+
} catch (err) {
|
|
1419
|
+
return { error: ghErrorDetail(err) || `${GH_PR_LIST} failed` };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
async function closedPrListOnce(repoPath, branchName, token, number) {
|
|
1424
|
+
try {
|
|
1425
|
+
const stdout = await runGh(closedPrViewArgs(number), { cwd: repoPath, token });
|
|
1426
|
+
return { pr: parseClosedPr(stdout, number) };
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
return { error: ghErrorDetail(err) || 'gh pr view failed' };
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Is this even a path we can ask gh about? Cheap local checks first, so a
|
|
1433
|
+
// removed repo or an undispatched job never shells out at all.
|
|
1434
|
+
async function isQueryableRepo(repoPath, branchName) {
|
|
1435
|
+
if (!repoPath || !branchName || !existsSync(repoPath)) return false;
|
|
1436
|
+
try {
|
|
1437
|
+
return !!(await gitExec(['-C', repoPath, 'rev-parse', '--git-dir']));
|
|
1438
|
+
} catch { return false; }
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// The account walk, shared by every gh lookup.
|
|
1442
|
+
//
|
|
1443
|
+
// Tries EVERY signed-in gh account before giving up. One machine can hold
|
|
1444
|
+
// several, and a private repo is usually visible to exactly one of them — this
|
|
1445
|
+
// app routinely manages repos owned by different accounts, so the signed-in
|
|
1446
|
+
// account failing says nothing about whether the PR exists. Observed:
|
|
1447
|
+
// `Could not resolve to a Repository` from the active account while another
|
|
1448
|
+
// account on the same machine could see the repo perfectly well.
|
|
1449
|
+
//
|
|
1450
|
+
// `query(token)` runs one lookup and returns `{ error }` to mean "this account
|
|
1451
|
+
// cannot see the repo" or anything else to mean success. A success ends the
|
|
1452
|
+
// walk and is handed back verbatim, so each lookup keeps its own return shape.
|
|
1453
|
+
//
|
|
1454
|
+
// The collaborators are injectable for the same reason createSession and
|
|
1455
|
+
// killSession are: otherwise the walk — ordering, dedup, remembering, error
|
|
1456
|
+
// aggregation — is only reachable by talking to real GitHub accounts.
|
|
1457
|
+
async function walkGhAccounts(repoPath, query, { listAccounts = ghAccountsCached, tokenFor = ghTokenCached, label = GH_PR_LIST } = {}) {
|
|
1458
|
+
// Label for a lookup that uses no explicit token — whatever gh picks itself.
|
|
1459
|
+
const AMBIENT = 'signed-in account';
|
|
1460
|
+
const tried = [];
|
|
1461
|
+
const failures = [];
|
|
1462
|
+
|
|
1463
|
+
const attempt = async (label, token) => {
|
|
1464
|
+
if (tried.includes(label)) return null;
|
|
1465
|
+
tried.push(label);
|
|
1466
|
+
const result = await query(token);
|
|
1467
|
+
// Detail only — the account list in the final message names who was tried,
|
|
1468
|
+
// and the errors are almost always identical across accounts anyway.
|
|
1469
|
+
if (result.error) { failures.push(result.error); return null; }
|
|
1470
|
+
return result;
|
|
1471
|
+
};
|
|
1472
|
+
|
|
1473
|
+
// 1. The account that answered for this repo last time.
|
|
1474
|
+
const remembered = ghAccountForRepo.get(repoPath);
|
|
1475
|
+
if (remembered) {
|
|
1476
|
+
const token = await tokenFor(remembered);
|
|
1477
|
+
if (token) {
|
|
1478
|
+
const hit = await attempt(remembered, token);
|
|
1479
|
+
if (hit) return { ok: true, result: hit };
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// 2. Every account gh knows about, active first, each with its own token.
|
|
1484
|
+
//
|
|
1485
|
+
// Named accounts rather than "whatever gh is signed in as" so the walk is
|
|
1486
|
+
// unambiguous: an earlier version tried the ambient account first and then
|
|
1487
|
+
// skipped accounts[0] to avoid repeating it, which quietly assumed the first
|
|
1488
|
+
// entry was the account ambient had used. With more than one host configured
|
|
1489
|
+
// that assumption can skip the only account able to see the repo.
|
|
1490
|
+
const accounts = await listAccounts();
|
|
1491
|
+
for (const login of accounts) {
|
|
1492
|
+
const token = await tokenFor(login);
|
|
1493
|
+
if (!token) continue;
|
|
1494
|
+
const hit = await attempt(login, token);
|
|
1495
|
+
if (hit) { ghAccountForRepo.set(repoPath, login); return { ok: true, result: hit }; }
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// 3. Last resort: no explicit token. Covers a token supplied through the
|
|
1499
|
+
// environment, an enterprise host, and older gh versions whose auth status
|
|
1500
|
+
// this cannot enumerate.
|
|
1501
|
+
const ambient = await attempt(AMBIENT, undefined);
|
|
1502
|
+
if (ambient) { ghAccountForRepo.delete(repoPath); return { ok: true, result: ambient }; }
|
|
1503
|
+
|
|
1504
|
+
ghAccountForRepo.delete(repoPath);
|
|
1505
|
+
const plural = tried.length === 1 ? '' : 's';
|
|
1506
|
+
return {
|
|
1507
|
+
ok: false,
|
|
1508
|
+
error: `${failures[0] || `${label} failed`} (tried ${tried.length} account${plural}: ${tried.join(', ')})`,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// `gh pr list` against the repo, filtered to the job's branch. Runs in the main
|
|
1513
|
+
// repo (not the worktree) so it still works after the worktree is removed.
|
|
1514
|
+
//
|
|
1515
|
+
// Returns { pr } on a successful query — pr null meaning "no PR yet" — or
|
|
1516
|
+
// { pr: null, error } only when EVERY account failed. An earlier version
|
|
1517
|
+
// collapsed both into null, so a repo the board could never see looked exactly
|
|
1518
|
+
// like a branch with no PR, and its jobs sat in progress forever unexplained.
|
|
1519
|
+
// Guard, walk, unwrap — identical for both branch lookups; only the query
|
|
1520
|
+
// differs. They stay two named exports rather than one function with a flag so
|
|
1521
|
+
// each call site says which question it is asking.
|
|
1522
|
+
async function branchPrLookup(repoPath, branchName, query, { listAccounts, tokenFor, label }) {
|
|
1523
|
+
if (!(await isQueryableRepo(repoPath, branchName))) return { pr: null };
|
|
1524
|
+
const walked = await walkGhAccounts(repoPath, query, { listAccounts, tokenFor, label });
|
|
1525
|
+
return walked.ok ? walked.result : { pr: null, error: walked.error };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
export async function findPrForBranch(repoPath, branchName, {
|
|
1529
|
+
listAccounts = ghAccountsCached,
|
|
1530
|
+
tokenFor = ghTokenCached,
|
|
1531
|
+
prList = prListOnce,
|
|
1532
|
+
} = {}) {
|
|
1533
|
+
return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token),
|
|
1534
|
+
{ listAccounts, tokenFor, label: GH_PR_LIST });
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// The same lookup, asking instead whether the branch's PR has MERGED. Same
|
|
1538
|
+
// account walk, same error contract — { pr: null } means "not merged (yet)",
|
|
1539
|
+
// and an error means nobody could see the repo.
|
|
1540
|
+
//
|
|
1541
|
+
// `prNumber` and `mergedAfter` are what make the answer about THIS card rather
|
|
1542
|
+
// than about the branch name — see parseMergedPr for why the distinction is not
|
|
1543
|
+
// academic. They are passed on to the parser, not to gh: `gh pr list` has no
|
|
1544
|
+
// way to ask "the merge of PR #7", only "merges on this head ref".
|
|
1545
|
+
export async function findMergedPrForBranch(repoPath, branchName, {
|
|
1546
|
+
listAccounts = ghAccountsCached,
|
|
1547
|
+
tokenFor = ghTokenCached,
|
|
1548
|
+
prList = mergedPrListOnce,
|
|
1549
|
+
prNumber = null,
|
|
1550
|
+
mergedAfter = null,
|
|
1551
|
+
} = {}) {
|
|
1552
|
+
const match = { number: prNumber, mergedAfter };
|
|
1553
|
+
return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token, match),
|
|
1554
|
+
{ listAccounts, tokenFor, label: `${GH_PR_LIST} --state merged` });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// The card's PR of record, if it was closed without merging.
|
|
1558
|
+
export async function findClosedPrForBranch(repoPath, branchName, {
|
|
1559
|
+
listAccounts = ghAccountsCached,
|
|
1560
|
+
tokenFor = ghTokenCached,
|
|
1561
|
+
prList = closedPrListOnce,
|
|
1562
|
+
prNumber = null,
|
|
1563
|
+
} = {}) {
|
|
1564
|
+
return branchPrLookup(repoPath, branchName, token => prList(repoPath, branchName, token, prNumber),
|
|
1565
|
+
{ listAccounts, tokenFor, label: 'gh pr view' });
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Close the agent that delivered a job, resolving it by branch when the stored
|
|
1569
|
+
// link is gone. killSession -> removeWorktree deletes the worktree and the local
|
|
1570
|
+
// branch (fully pushed by then); the PR is untouched. The card keeps the whole
|
|
1571
|
+
// record — agent name, branch, PR link — so nothing is lost by the terminal
|
|
1572
|
+
// going away, and the work itself is on the remote.
|
|
1573
|
+
//
|
|
1574
|
+
// Resolved by branch because a restart nulls every agentSessionId, and an agent
|
|
1575
|
+
// re-adopted afterwards is found by its branch: created per job, not reused
|
|
1576
|
+
// while it exists, durable across restarts.
|
|
1577
|
+
//
|
|
1578
|
+
// Called when the merge sweep files a card as Done. The PR poll and finish_job
|
|
1579
|
+
// keep the agent — Review is finished work with its agent still on hand — and
|
|
1580
|
+
// manual moves retire it in moveJob, by the linked session.
|
|
1581
|
+
//
|
|
1582
|
+
// Returns whether it actually closed something. A failure is logged and
|
|
1583
|
+
// swallowed: the work has shipped either way, so a cleanup that did not work
|
|
1584
|
+
// must not strand the card.
|
|
1585
|
+
// byBranch: false restricts it to the linked session. A Review card's agent is
|
|
1586
|
+
// always linked (a re-adopt relinks it), so an unlinked session on that branch
|
|
1587
|
+
// is one someone opened by hand, and a merge is no reason to close it.
|
|
1588
|
+
async function retireAgentForJob(job, askedBranch, askedSessionId, killSession, { byBranch = true } = {}) {
|
|
1589
|
+
if (!killSession) return false;
|
|
1590
|
+
const linked = askedSessionId && askedSessionId === job.agentSessionId
|
|
1591
|
+
? sessions.get(askedSessionId)
|
|
1592
|
+
: null;
|
|
1593
|
+
const session = linked || byBranch && [...sessions.values()].find(candidate =>
|
|
1594
|
+
candidate && !candidate.exited
|
|
1595
|
+
&& candidate.branchName === askedBranch
|
|
1596
|
+
&& candidate.repoPath === job.repoPath,
|
|
1597
|
+
);
|
|
1598
|
+
if (!session || session.exited) return false;
|
|
1599
|
+
try {
|
|
1600
|
+
if (!job.agentName) job.agentName = session.name;
|
|
1601
|
+
await killSession(session.id);
|
|
1602
|
+
job.agentSessionId = null; // only after the kill actually succeeded
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch (err) {
|
|
1605
|
+
console.error(`Failed to close agent for job "${job.title}":`, err.message);
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// `findPr` is injected for the same reason createSession and killSession are:
|
|
1611
|
+
// an internal call to findPrForBranch is bound directly by the module system
|
|
1612
|
+
// and cannot be substituted from outside, so the PR-to-review transition would
|
|
1613
|
+
// otherwise only be testable by talking to GitHub.
|
|
1614
|
+
//
|
|
1615
|
+
// The fallback for an agent that opened its PR but never called finish_job:
|
|
1616
|
+
// the same move, found by polling. Cards that require no PR are skipped — their
|
|
1617
|
+
// agent reports its own finish, and a PR on one proves nothing about that.
|
|
1618
|
+
// The agent is kept, as finish_job keeps it; Done is what retires it.
|
|
1619
|
+
export async function checkPullRequests(broadcast, { findPr = findPrForBranch } = {}) {
|
|
1620
|
+
const inProgress = allJobs().filter(j => j.state === 'in-progress' && j.branchName && jobRequiresPr(j));
|
|
1621
|
+
if (inProgress.length === 0) return [];
|
|
1622
|
+
const moved = [];
|
|
1623
|
+
let noted = false; // a PR-check failure was recorded on some card
|
|
1624
|
+
for (const job of inProgress) {
|
|
1625
|
+
// Capture what we are asking about: findPr is a network call, and a
|
|
1626
|
+
// requeue or delete during it would otherwise let us apply a PR result to
|
|
1627
|
+
// a job that has since moved on — silently undoing the user's action, or
|
|
1628
|
+
// killing an agent that belongs to a different attempt.
|
|
1629
|
+
const askedBranch = job.branchName;
|
|
1630
|
+
const { pr, error } = await findPr(job.repoPath, askedBranch);
|
|
1631
|
+
|
|
1632
|
+
// Re-validate before touching the job at all. findPr is a network call and
|
|
1633
|
+
// WebSocket handlers run during it, so the job may have been deleted,
|
|
1634
|
+
// requeued or moved while we waited — writing either a result OR an error
|
|
1635
|
+
// onto it then lands on a job that has moved on, or on a detached object.
|
|
1636
|
+
if (!allJobs().includes(job) || job.state !== 'in-progress' || job.branchName !== askedBranch) continue;
|
|
1637
|
+
|
|
1638
|
+
if (error) {
|
|
1639
|
+
// Say so on the card. Without this the job looks like an agent that went
|
|
1640
|
+
// quiet, and the user has no way to learn the board simply cannot see
|
|
1641
|
+
// this repo's pull requests. Only written when the message changes, so a
|
|
1642
|
+
// persistent failure does not rewrite config.json every five minutes.
|
|
1643
|
+
// Its own field, not lastError. A job can already be carrying a restart
|
|
1644
|
+
// note naming where its work is, and the two are both true at once: the
|
|
1645
|
+
// agent is gone AND the board cannot see this repo's pull requests.
|
|
1646
|
+
// Sharing one field meant either clobbering that note or suppressing this
|
|
1647
|
+
// one. Written only when the text changes, so a permanent failure does
|
|
1648
|
+
// not rewrite config.json every five minutes.
|
|
1649
|
+
const message = `Cannot check for a pull request here — ${error}. This job stays put until you move it by hand.`;
|
|
1650
|
+
if (notePrCheckError(job, message)) noted = true;
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// The query worked, so a previous "cannot check" note is wrong — clear it
|
|
1655
|
+
// even when there is still no PR, or a repo that regained access would keep
|
|
1656
|
+
// claiming it was unreachable until a PR happened to appear.
|
|
1657
|
+
if (clearPrCheckError(job)) noted = true;
|
|
1658
|
+
|
|
1659
|
+
if (!pr) continue;
|
|
1660
|
+
job.state = 'review';
|
|
1661
|
+
job.prUrl = pr.url;
|
|
1662
|
+
job.prNumber = pr.number;
|
|
1663
|
+
job.prClosedSeenAt = null;
|
|
1664
|
+
job.reviewAt = new Date().toISOString();
|
|
1665
|
+
// The restart note says the board is still watching for this PR. It just
|
|
1666
|
+
// found it, so the note is now false on its own card.
|
|
1667
|
+
job.lastError = null;
|
|
1668
|
+
job.lastErrorAt = null;
|
|
1669
|
+
moved.push(job);
|
|
1670
|
+
if (broadcast) {
|
|
1671
|
+
broadcast({ type: 'notification', level: 'info', message: `Job "${job.title}" moved to Review — PR #${pr.number}` });
|
|
1672
|
+
}
|
|
1673
|
+
notifyBillion(job);
|
|
1674
|
+
}
|
|
1675
|
+
if (moved.length > 0 || noted) persist(broadcast);
|
|
1676
|
+
return moved;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// -> Done, once the PR has merged.
|
|
1680
|
+
//
|
|
1681
|
+
// A merged PR is finished work. Leaving its card in Review means the column
|
|
1682
|
+
// slowly fills with things nobody has to look at again, and stops meaning
|
|
1683
|
+
// "needs your review" — so the card leaves the board while the job itself is
|
|
1684
|
+
// kept, reachable through Finished jobs.
|
|
1685
|
+
//
|
|
1686
|
+
// Covers In progress as well as Review, because a PR can open and merge inside
|
|
1687
|
+
// one scan interval and `gh pr list --state open` cannot see it afterwards: the
|
|
1688
|
+
// open query returns nothing, so checkPullRequests leaves the job in progress,
|
|
1689
|
+
// and a Review-only sweep would never look at it. That card would sit in In
|
|
1690
|
+
// progress forever reading "agent gone", holding a slot on the Jobs tab badge,
|
|
1691
|
+
// for work that had actually shipped — the exact case this whole change exists
|
|
1692
|
+
// to file away.
|
|
1693
|
+
//
|
|
1694
|
+
// Deliberately narrower than checkPullRequests in three ways:
|
|
1695
|
+
//
|
|
1696
|
+
// - Only THIS card's PR counts (see parseMergedPr). Merged files the card as
|
|
1697
|
+
// merged. Closed without merging files it too, marked prClosedAt: closing a
|
|
1698
|
+
// PR is already someone deciding, and a card left in Review would hold a
|
|
1699
|
+
// live agent and worktree for work nobody is going to land (and, on a
|
|
1700
|
+
// schedule's run, hold the schedule off). Unpushed work stays an orphan.
|
|
1701
|
+
// - Only cards that require a PR. One that does not has no PR of its own to
|
|
1702
|
+
// watch, and a branch name matching some merge proves nothing about it.
|
|
1703
|
+
//
|
|
1704
|
+
// Reaching Done retires the agent and releases its worktree from either
|
|
1705
|
+
// column, the same as a manual move to Done: merged means finished.
|
|
1706
|
+
// How long a closed reading must hold before the card is filed (see the
|
|
1707
|
+
// closed path in checkMergedPullRequests).
|
|
1708
|
+
export const CLOSED_CONFIRM_MS = 60_000;
|
|
1709
|
+
|
|
1710
|
+
export async function checkMergedPullRequests(broadcast, { killSession, findMerged = findMergedPrForBranch, findClosed = findClosedPrForBranch, findPr = findPrForBranch } = {}) {
|
|
1711
|
+
// prMergedAt is only ever written alongside state 'done', and done is
|
|
1712
|
+
// terminal, so the state filter already excludes every stamped job. Kept as a
|
|
1713
|
+
// cheap assertion of that invariant rather than a live condition.
|
|
1714
|
+
// A schedule has no branch, so it never matches. A run whose PR merges goes
|
|
1715
|
+
// to Done here like any card, which is what lets its schedule fire again.
|
|
1716
|
+
// Before the early return, so a card with nothing left to merge still gets
|
|
1717
|
+
// its straggler files freed.
|
|
1718
|
+
const recleared = clearFinishedAttachments();
|
|
1719
|
+
const candidates = allJobs().filter(j =>
|
|
1720
|
+
(j.state === 'review' || j.state === 'in-progress') && j.branchName && !j.prMergedAt && jobRequiresPr(j));
|
|
1721
|
+
if (candidates.length === 0) {
|
|
1722
|
+
if (recleared) persist(broadcast);
|
|
1723
|
+
return [];
|
|
1724
|
+
}
|
|
1725
|
+
const finished = [];
|
|
1726
|
+
let noted = false; // a PR-check failure was recorded on some card
|
|
1727
|
+
for (const job of candidates) {
|
|
1728
|
+
// Captured for the same reason checkPullRequests captures them: findMerged
|
|
1729
|
+
// is a network call, and a move or delete during it would otherwise let us
|
|
1730
|
+
// apply the answer to a job that has since moved on.
|
|
1731
|
+
const askedBranch = job.branchName;
|
|
1732
|
+
const askedState = job.state;
|
|
1733
|
+
// What makes the answer about this card and not about the branch name: its
|
|
1734
|
+
// PR of record when it has one, otherwise the earliest merge that could be
|
|
1735
|
+
// its work. Board branch names outlive their branches and get reused.
|
|
1736
|
+
const { pr, error } = await findMerged(job.repoPath, askedBranch, {
|
|
1737
|
+
prNumber: job.prNumber ?? null,
|
|
1738
|
+
mergedAfter: job.reviewAt || job.startedAt || null,
|
|
1739
|
+
});
|
|
1740
|
+
|
|
1741
|
+
if (!allJobs().includes(job) || job.state !== askedState || job.branchName !== askedBranch) continue;
|
|
1742
|
+
|
|
1743
|
+
if (error) {
|
|
1744
|
+
// Same field as the in-progress check, and for the same reason: the user
|
|
1745
|
+
// needs to know the board cannot see this repo's pull requests rather
|
|
1746
|
+
// than assuming nothing has merged.
|
|
1747
|
+
// Only reported for a Review card. An in-progress job is already covered
|
|
1748
|
+
// by checkPullRequests' own note about the same repo, and writing a
|
|
1749
|
+
// second one over it would just churn the field between the two sweeps.
|
|
1750
|
+
if (askedState === 'review') {
|
|
1751
|
+
const message = `Cannot check whether this pull request merged — ${error}. This job stays in Review until you move it by hand.`;
|
|
1752
|
+
if (notePrCheckError(job, message)) noted = true;
|
|
1753
|
+
}
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// A Review card with a PR still has the closed-PR lookups ahead; its note
|
|
1758
|
+
// is cleared only once those succeed too, or a lasting failure there would
|
|
1759
|
+
// clear and rewrite it on every scan.
|
|
1760
|
+
const closedLookupAhead = !pr && askedState === 'review' && job.prNumber != null;
|
|
1761
|
+
if (askedState === 'review' && !closedLookupAhead && clearPrCheckError(job)) noted = true;
|
|
1762
|
+
|
|
1763
|
+
if (!pr) {
|
|
1764
|
+
if (askedState === 'review' && job.prNumber != null) {
|
|
1765
|
+
const prNumber = job.prNumber;
|
|
1766
|
+
const still = () => allJobs().includes(job) && job.state === 'review'
|
|
1767
|
+
&& job.prNumber === prNumber && job.branchName === askedBranch;
|
|
1768
|
+
const { pr: closedPr, error: closedError } = await findClosed(job.repoPath, askedBranch, { prNumber });
|
|
1769
|
+
if (!still()) continue;
|
|
1770
|
+
if (closedError) {
|
|
1771
|
+
if (notePrCheckError(job, `Cannot check whether this pull request was closed — ${closedError}. This job stays in Review until you move it by hand.`)) noted = true;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (!closedPr) {
|
|
1775
|
+
if (clearPrCheckError(job)) noted = true;
|
|
1776
|
+
if (job.prClosedSeenAt) { job.prClosedSeenAt = null; noted = true; }
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
// Closed and replaced: an agent asked to rework often closes its PR
|
|
1780
|
+
// and opens another on the same branch. That new PR is the card's now.
|
|
1781
|
+
// Any open PR means "not closed": the same number reopened in between.
|
|
1782
|
+
// A failed lookup decides nothing — the replacement may be there.
|
|
1783
|
+
const { pr: openPr, error: openError } = await findPr(job.repoPath, askedBranch);
|
|
1784
|
+
if (!still()) continue;
|
|
1785
|
+
if (openError) {
|
|
1786
|
+
if (notePrCheckError(job, `Cannot check for a replacement pull request — ${openError}. This job stays in Review until you move it by hand.`)) noted = true;
|
|
1787
|
+
continue;
|
|
1788
|
+
}
|
|
1789
|
+
if (clearPrCheckError(job)) noted = true;
|
|
1790
|
+
if (openPr) {
|
|
1791
|
+
if (openPr.number !== prNumber) {
|
|
1792
|
+
job.prNumber = openPr.number;
|
|
1793
|
+
job.prUrl = openPr.url;
|
|
1794
|
+
}
|
|
1795
|
+
if (job.prClosedSeenAt || openPr.number !== prNumber) { job.prClosedSeenAt = null; noted = true; }
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
// Two readings at least CLOSED_CONFIRM_MS apart before acting: closing
|
|
1799
|
+
// and reopening a PR (to re-run CI, to retarget it) must not file the
|
|
1800
|
+
// card away, and "Run now" right after a tick must not count as two.
|
|
1801
|
+
if (!job.prClosedSeenAt) { job.prClosedSeenAt = new Date().toISOString(); noted = true; continue; }
|
|
1802
|
+
if (Date.now() - Date.parse(job.prClosedSeenAt) < CLOSED_CONFIRM_MS) continue;
|
|
1803
|
+
// Someone may be talking to its agent about the rework; wait until it
|
|
1804
|
+
// is quiet, as superseding does.
|
|
1805
|
+
const live = job.agentSessionId ? sessions.get(job.agentSessionId) : null;
|
|
1806
|
+
if (live && !live.exited && live.state !== 'WAITING') continue;
|
|
1807
|
+
job.state = 'done';
|
|
1808
|
+
job.prClosedAt = new Date().toISOString();
|
|
1809
|
+
job.doneAt = job.prClosedAt;
|
|
1810
|
+
finished.push(job);
|
|
1811
|
+
// Not discarded: a closed PR's work may still be wanted, so anything
|
|
1812
|
+
// unpushed or dirty stays behind as an orphan.
|
|
1813
|
+
const closedAgent = await retireAgentForJob(job, askedBranch, job.agentSessionId, killSession, { byBranch: false });
|
|
1814
|
+
if (!closedAgent) await releaseOrphanedWorktree(job, broadcast);
|
|
1815
|
+
if (!attachmentsInUse(job)) clearAttachments(job);
|
|
1816
|
+
if (broadcast) {
|
|
1817
|
+
broadcast({
|
|
1818
|
+
type: 'notification', level: 'info',
|
|
1819
|
+
message: `Job "${job.title}" is done — PR #${prNumber} was closed without merging. It moved to Finished jobs.`
|
|
1820
|
+
+ (job.scheduleId ? ' Its schedule can run again.' : ''),
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
job.state = 'done';
|
|
1827
|
+
job.prMergedAt = pr.mergedAt || new Date().toISOString();
|
|
1828
|
+
job.doneAt = new Date().toISOString();
|
|
1829
|
+
// The merged PR is the authority on where the work ended up: a card that
|
|
1830
|
+
// never reached Review has no URL yet, and this is the moment one exists.
|
|
1831
|
+
if (pr.url) job.prUrl = pr.url;
|
|
1832
|
+
if (pr.number != null) job.prNumber = pr.number;
|
|
1833
|
+
if (!job.reviewAt) job.reviewAt = job.doneAt;
|
|
1834
|
+
finished.push(job);
|
|
1835
|
+
|
|
1836
|
+
// The link as it is now, not as it was asked about: an agent re-adopted
|
|
1837
|
+
// during the lookup is the one to retire, or it outlives its card.
|
|
1838
|
+
const closed = await retireAgentForJob(job, askedBranch, job.agentSessionId, killSession,
|
|
1839
|
+
{ byBranch: askedState === 'in-progress' });
|
|
1840
|
+
const released = !closed && await releaseOrphanedWorktree(job, broadcast);
|
|
1841
|
+
// After the retire, so a just-killed agent no longer counts as in use. A
|
|
1842
|
+
// kill that failed leaves the files held; the retry pass at the top of
|
|
1843
|
+
// this sweep frees them once that session exits.
|
|
1844
|
+
if (!attachmentsInUse(job)) clearAttachments(job);
|
|
1845
|
+
if (broadcast) {
|
|
1846
|
+
broadcast({
|
|
1847
|
+
type: 'notification', level: 'info',
|
|
1848
|
+
message: `Job "${job.title}" is done — PR #${job.prNumber} merged. It moved to Finished jobs.`
|
|
1849
|
+
+ (closed ? ` · ${job.agentName} closed, worktree released` : '')
|
|
1850
|
+
+ (released ? ' · its orphaned worktree was released' : ''),
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (finished.length > 0 || noted || recleared) persist(broadcast);
|
|
1855
|
+
return finished;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
// --- Schedules ---
|
|
1859
|
+
|
|
1860
|
+
// Post a run for every schedule that has come due, or note why it held off
|
|
1861
|
+
// (see scheduleHold). Either way the schedule moves on to its next time, so a
|
|
1862
|
+
// firing it skipped is not replayed later — the same rule a pause follows.
|
|
1863
|
+
//
|
|
1864
|
+
// Runs FIRST in the dispatch half of the scan, so a run it posts goes out on
|
|
1865
|
+
// this same scan rather than waiting a whole interval in To do.
|
|
1866
|
+
export function fireSchedules(broadcast, { now = Date.now() } = {}) {
|
|
1867
|
+
const fired = [];
|
|
1868
|
+
let changed = false;
|
|
1869
|
+
for (const schedule of allJobs().filter(j => isScheduled(j) && j.state === 'todo')) {
|
|
1870
|
+
if (!isJobDue(schedule, now)) continue;
|
|
1871
|
+
changed = true;
|
|
1872
|
+
schedule.nextRunAt = schedule.schedule ? nextCronIso(schedule.schedule, now) : null;
|
|
1873
|
+
const hold = scheduleHold(schedule, allJobs());
|
|
1874
|
+
if (hold) {
|
|
1875
|
+
schedule.lastSkipAt = new Date(now).toISOString();
|
|
1876
|
+
schedule.lastSkipReason = hold;
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
const result = createRunJob(schedule);
|
|
1880
|
+
if (result.error) {
|
|
1881
|
+
schedule.lastError = `Could not post this run: ${result.error}`;
|
|
1882
|
+
schedule.lastErrorAt = new Date(now).toISOString();
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
// The run gets its own copy of the schedule's files, so neither card's
|
|
1886
|
+
// cleanup — a run filed to Done, the schedule deleted or edited — can take
|
|
1887
|
+
// a file out from under the other.
|
|
1888
|
+
const copied = copyRunAttachments(result.job, schedule);
|
|
1889
|
+
if (copied.error) {
|
|
1890
|
+
schedule.lastError = `Could not post this run: ${copied.error}`;
|
|
1891
|
+
schedule.lastErrorAt = new Date(now).toISOString();
|
|
1892
|
+
continue;
|
|
1893
|
+
}
|
|
1894
|
+
allJobs().push(result.job);
|
|
1895
|
+
schedule.runCount = (Number(schedule.runCount) || 0) + 1;
|
|
1896
|
+
schedule.lastRunAt = new Date(now).toISOString();
|
|
1897
|
+
schedule.lastRunJobId = result.job.id;
|
|
1898
|
+
schedule.lastSkipAt = null;
|
|
1899
|
+
schedule.lastSkipReason = null;
|
|
1900
|
+
schedule.lastError = null;
|
|
1901
|
+
schedule.lastErrorAt = null;
|
|
1902
|
+
fired.push(result.job);
|
|
1903
|
+
}
|
|
1904
|
+
if (changed) persist(broadcast);
|
|
1905
|
+
return fired;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
function copyRunAttachments(run, schedule) {
|
|
1909
|
+
const files = (schedule.attachments || []).filter(a => a && a.path && insideAttachments(a.path) && existsSync(a.path));
|
|
1910
|
+
if (!files.length) { run.attachments = []; return {}; }
|
|
1911
|
+
const dir = attachmentDir(run.id);
|
|
1912
|
+
try {
|
|
1913
|
+
mkdirSync(dir, { recursive: true });
|
|
1914
|
+
run.attachments = files.map(a => {
|
|
1915
|
+
const path = join(dir, safeFilename(a.name));
|
|
1916
|
+
copyFileSync(a.path, path);
|
|
1917
|
+
return { name: basename(path), path };
|
|
1918
|
+
});
|
|
1919
|
+
return {};
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* the error below is what matters */ }
|
|
1922
|
+
return { error: `copying its files failed: ${err.message}` };
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// Keep each schedule's finished runs to the newest MAX_FINISHED_RUNS (see
|
|
1927
|
+
// runsToPrune), attachment folders included. One pass and one save, however
|
|
1928
|
+
// many go: deleteJob per run would rewrite config.json and rebroadcast the
|
|
1929
|
+
// board once for each. A run whose agent is still alive (a kill at Done that
|
|
1930
|
+
// failed) is left for a later scan, so no agent loses the card pointing at it.
|
|
1931
|
+
export function pruneFinishedRuns(broadcast) {
|
|
1932
|
+
const pruned = runsToPrune(allJobs()).filter(run => !attachmentsInUse(run));
|
|
1933
|
+
if (!pruned.length) return [];
|
|
1934
|
+
const gone = new Set(pruned.map(run => run.id));
|
|
1935
|
+
config.jobs = allJobs().filter(job => !gone.has(job.id));
|
|
1936
|
+
persist(broadcast);
|
|
1937
|
+
for (const run of pruned) {
|
|
1938
|
+
const dir = attachmentDir(run.id);
|
|
1939
|
+
if (!insideAttachments(dir)) continue;
|
|
1940
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch (err) {
|
|
1941
|
+
console.error(`Failed to remove attachments for pruned run "${run.title}":`, err.message);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
return pruned;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// File away every no-PR run that a newer run of the same schedule has
|
|
1948
|
+
// replaced in Review, and retire its agent — through moveJob, so it is the
|
|
1949
|
+
// same Done as a person pressing the button. The newest run records how many
|
|
1950
|
+
// it replaced, so a board nobody read for a day says so.
|
|
1951
|
+
export async function supersedeRuns(broadcast, { killSession } = {}) {
|
|
1952
|
+
const replaced = [];
|
|
1953
|
+
for (const { old, by } of supersededRuns(allJobs())) {
|
|
1954
|
+
// Rechecked per run: each move awaits a kill and git, and a person can
|
|
1955
|
+
// requeue or delete either card in the meantime.
|
|
1956
|
+
if (!allJobs().includes(old) || !allJobs().includes(by) || old.state !== 'review' || by.state !== 'review') continue;
|
|
1957
|
+
// An agent working a follow-up or asking something is someone's live
|
|
1958
|
+
// conversation; it is superseded on a later scan, once it is quiet.
|
|
1959
|
+
const session = old.agentSessionId ? sessions.get(old.agentSessionId) : null;
|
|
1960
|
+
if (session && !session.exited && session.state !== 'WAITING') continue;
|
|
1961
|
+
// Its result is the summary it reported, so what it left in the worktree is
|
|
1962
|
+
// scratch; an hourly schedule would otherwise orphan a worktree a run.
|
|
1963
|
+
const result = await moveJob(old.id, 'done', broadcast, { killSession, discardChanges: true });
|
|
1964
|
+
if (result.error) continue;
|
|
1965
|
+
old.supersededBy = by.id;
|
|
1966
|
+
by.supersededRuns = (Number(by.supersededRuns) || 0) + 1 + (Number(old.supersededRuns) || 0);
|
|
1967
|
+
replaced.push(old);
|
|
1968
|
+
}
|
|
1969
|
+
if (replaced.length) {
|
|
1970
|
+
persist(broadcast);
|
|
1971
|
+
if (broadcast) {
|
|
1972
|
+
broadcast({
|
|
1973
|
+
type: 'notification', level: 'info',
|
|
1974
|
+
message: `Filed ${replaced.length} earlier run${replaced.length === 1 ? '' : 's'} to Finished — a newer run is in Review`,
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
return replaced;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// --- Scan ---
|
|
1982
|
+
|
|
1983
|
+
// One scan = find PRs, file away superseded and merged runs, fire due
|
|
1984
|
+
// schedules, then dispatch.
|
|
1985
|
+
// Both entry points (the interval timer and the "Run now" button) go through
|
|
1986
|
+
// here, behind a single in-flight flag.
|
|
1987
|
+
//
|
|
1988
|
+
// The guard is not decorative. dispatchOnce reads job.state to pick candidates,
|
|
1989
|
+
// then awaits createSession, and only sets state = 'in-progress' after that
|
|
1990
|
+
// await returns. Two overlapping scans therefore both see the same job as
|
|
1991
|
+
// 'todo' and both dispatch it: two agents, two worktrees, two branches for one
|
|
1992
|
+
// job, with job.agentSessionId keeping only the last — the other agent is
|
|
1993
|
+
// orphaned, invisible to the board, and never cleaned up. It also lets the
|
|
1994
|
+
// per-repo cap be exceeded, since both scans read the same in-flight count.
|
|
1995
|
+
// The window is wide (createSession does addRepo + `git worktree add` + a PTY
|
|
1996
|
+
// spawn) and trivially hit by clicking Run now while a tick is in flight.
|
|
1997
|
+
let scanInFlight = false;
|
|
1998
|
+
|
|
1999
|
+
// findPr/findMerged are forwarded rather than left to their defaults so the
|
|
2000
|
+
// order of the scan — PRs found, then merges swept, then dispatch — is
|
|
2001
|
+
// reachable from a test without talking to GitHub.
|
|
2002
|
+
export async function runScan(createSession, broadcast, { onSessionCreated, killSession, findPr, findMerged, findClosed } = {}) {
|
|
2003
|
+
if (scanInFlight) return { skipped: true };
|
|
2004
|
+
scanInFlight = true;
|
|
2005
|
+
try {
|
|
2006
|
+
await checkPullRequests(broadcast, { findPr });
|
|
2007
|
+
await supersedeRuns(broadcast, { killSession });
|
|
2008
|
+
await checkMergedPullRequests(broadcast, { killSession, findMerged, findClosed, findPr });
|
|
2009
|
+
pruneFinishedRuns(broadcast);
|
|
2010
|
+
fireSchedules(broadcast);
|
|
2011
|
+
await dispatchOnce(createSession, broadcast, { onSessionCreated, killSession });
|
|
2012
|
+
return { skipped: false };
|
|
2013
|
+
} finally {
|
|
2014
|
+
scanInFlight = false;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// --- Loop ---
|
|
2019
|
+
|
|
2020
|
+
let dispatchTimer = null;
|
|
2021
|
+
|
|
2022
|
+
// Self-rescheduling rather than setInterval so a slow git/gh pass can never
|
|
2023
|
+
// overlap the next tick (same reasoning as startTreeScanLoop in git.js).
|
|
2024
|
+
export function startDispatcher(createSession, broadcast, { onSessionCreated, killSession } = {}) {
|
|
2025
|
+
stopDispatcher();
|
|
2026
|
+
const tick = async () => {
|
|
2027
|
+
try {
|
|
2028
|
+
if (boardSettings().running) {
|
|
2029
|
+
await runScan(createSession, broadcast, { onSessionCreated, killSession });
|
|
2030
|
+
}
|
|
2031
|
+
} catch (err) {
|
|
2032
|
+
console.error('Job dispatcher tick failed:', err.message);
|
|
2033
|
+
}
|
|
2034
|
+
dispatchTimer = setTimeout(tick, boardSettings().intervalMs);
|
|
2035
|
+
};
|
|
2036
|
+
// First tick soon after start so pressing Start feels responsive, rather than
|
|
2037
|
+
// appearing to do nothing until the first full interval elapses.
|
|
2038
|
+
dispatchTimer = setTimeout(tick, 2000);
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
export function stopDispatcher() {
|
|
2042
|
+
clearTimeout(dispatchTimer);
|
|
2043
|
+
dispatchTimer = null;
|
|
2044
|
+
}
|