atris 3.68.8 → 3.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ 'use strict';
2
+
3
+ // After an ignited member run ends with its check passing, hand the branch to
4
+ // GitHub auto-merge. Every local gate fails closed: the branch stays put and
5
+ // one plain line says which gate held it. GitHub then waits for the branch to
6
+ // be up to date and CI green; this never merges, rebases, or polls itself.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const {
12
+ deniedTagOf,
13
+ declaredProtectedLane,
14
+ sniffedProtectedLane,
15
+ } = require('./auto-accept-certified');
16
+ const { isDecisionHoldTag, isDecisionTask } = require('./task-decision');
17
+
18
+ // Git replacement refs can make a local check read different code than the
19
+ // commit that gets pushed; every git and gh call here ignores them.
20
+ function run(cmd, args, cwd) {
21
+ const env = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' };
22
+ const res = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120000, env });
23
+ return {
24
+ ok: res.status === 0,
25
+ stdout: String(res.stdout || '').trim(),
26
+ stderr: String(res.stderr || res.error?.message || '').trim(),
27
+ };
28
+ }
29
+
30
+ // The policy lives in the main checkout, not the mission's worktree copy.
31
+ function mainCheckoutRoot(cwd) {
32
+ const common = run('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
33
+ if (!common.ok || !common.stdout) return null;
34
+ return path.dirname(common.stdout);
35
+ }
36
+
37
+ function policyOn(cwd) {
38
+ const root = mainCheckoutRoot(cwd);
39
+ if (!root) return false;
40
+ try {
41
+ const policy = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'policy', 'autoland.json'), 'utf8'));
42
+ return Boolean(policy) && typeof policy === 'object' && policy.enabled === true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ const TAG_KEYS = new Set(['tag', 'tags', 'todo_tags', 'lane']);
49
+ const METADATA_DEPTH_LIMIT = 6;
50
+ const METADATA_TEXT_CAP = 64 * 1024;
51
+
52
+ // Every string anywhere in metadata, and the ones anywhere below a tag key.
53
+ // Metadata too deep or too large to read whole returns null, which holds.
54
+ function metadataStrings(metadata) {
55
+ const texts = [];
56
+ const tags = [];
57
+ let size = 0;
58
+ const walk = (value, depth, tagged) => {
59
+ if (typeof value === 'string') {
60
+ size += value.length;
61
+ texts.push(value);
62
+ if (tagged) tags.push(value);
63
+ return size <= METADATA_TEXT_CAP;
64
+ }
65
+ if (!value || typeof value !== 'object') return true;
66
+ if (depth >= METADATA_DEPTH_LIMIT) return false;
67
+ if (Array.isArray(value)) return value.every((item) => walk(item, depth + 1, tagged));
68
+ return Object.entries(value).every(([key, item]) => walk(item, depth + 1, tagged || TAG_KEYS.has(key)));
69
+ };
70
+ return walk(metadata, 0, false) ? { texts, tags } : null;
71
+ }
72
+
73
+ // A row's protected lane, or null. Tags are its own tag and lane, every
74
+ // string under a tag key anywhere in metadata, and '#tag' words in the title.
75
+ // Text is the title, objective, and every string in metadata.
76
+ function protectedLane(row) {
77
+ if (!row || typeof row !== 'object') return 'unreadable';
78
+ // Metadata is a plain object or absent; anything else, including stored
79
+ // text that would not parse, cannot be checked.
80
+ const meta = row.metadata;
81
+ if (row.metadata_unreadable || (meta !== undefined && meta !== null && (typeof meta !== 'object' || Array.isArray(meta)))) {
82
+ return 'metadata could not be read';
83
+ }
84
+ const found = metadataStrings(meta);
85
+ if (!found) return 'metadata too large to check';
86
+ const title = String(row.title || '');
87
+ const tags = [
88
+ row.tag,
89
+ row.lane,
90
+ ...(Array.isArray(row.tags) ? row.tags : []),
91
+ ...found.tags,
92
+ ...(title.match(/#[A-Za-z0-9_-]+/g) || []).map((t) => t.slice(1)),
93
+ ];
94
+ // Decision rows wait for a person; the same rule member go's picker uses.
95
+ if (isDecisionTask(row)) return 'needs a human decision';
96
+ for (const tag of tags) {
97
+ const text = String(tag ?? '');
98
+ if ([text, ...text.split(/[\s,]+/)].some(isDecisionHoldTag)) return 'needs a human decision';
99
+ const denied = deniedTagOf({ tag: text });
100
+ if (denied) return denied;
101
+ }
102
+ const objective = [title, typeof row.objective === 'string' ? row.objective : '', ...found.texts].filter(Boolean).join('\n');
103
+ const view = { title, objective, metadata: {} };
104
+ const declared = declaredProtectedLane(view);
105
+ if (declared) return `declared "${declared.phrase}"`;
106
+ const sniffed = sniffedProtectedLane(view);
107
+ if (sniffed) return sniffed.lane;
108
+ return null;
109
+ }
110
+
111
+ // Every task this mission points at, or null when a link is malformed.
112
+ function linkedTaskIds(mission) {
113
+ const ids = [];
114
+ if (mission.task_ids !== undefined) {
115
+ if (!Array.isArray(mission.task_ids)) return null;
116
+ for (const id of mission.task_ids) {
117
+ if (typeof id !== 'string' || !id.trim()) return null;
118
+ ids.push(id.trim());
119
+ }
120
+ }
121
+ const xp = mission.xp_task;
122
+ if (xp !== undefined && xp !== null && (typeof xp !== 'object' || Array.isArray(xp))) return null;
123
+ for (const value of [mission.task_id, mission.task_ref, mission.current_task_id, xp?.task_id, xp?.ref]) {
124
+ if (value === undefined || value === null || value === '') continue;
125
+ if (typeof value !== 'string' || !value.trim()) return null;
126
+ ids.push(value.trim());
127
+ }
128
+ return [...new Set(ids)];
129
+ }
130
+
131
+ function defaultLoadTask(id) {
132
+ try {
133
+ const taskDb = require('./task-db');
134
+ return taskDb.getTask(taskDb.open(), id) || null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ const RECEIPT_STAMP_RE = /^\d{4}-\d{2}-\d{2}T[0-9-]+Z\.json$/;
141
+
142
+ // The newest run receipt this mission wrote in its own folder, or null.
143
+ function latestRunReceipt(missionId, cwd) {
144
+ if (typeof missionId !== 'string' || !/^[A-Za-z0-9._-]+$/.test(missionId)) return null;
145
+ const dir = path.join(cwd, 'atris', 'runs');
146
+ const prefix = `mission-${missionId}-`;
147
+ let names;
148
+ try {
149
+ names = fs.readdirSync(dir);
150
+ } catch {
151
+ return null;
152
+ }
153
+ const latest = names
154
+ .filter((name) => name.startsWith(prefix) && RECEIPT_STAMP_RE.test(name.slice(prefix.length)))
155
+ .sort((a, b) => (a.slice(prefix.length) < b.slice(prefix.length) ? -1 : 1))
156
+ .pop();
157
+ if (!latest) return null;
158
+ try {
159
+ const receipt = JSON.parse(fs.readFileSync(path.join(dir, latest), 'utf8'));
160
+ if (!receipt || typeof receipt !== 'object' || receipt.mission_id !== missionId) return null;
161
+ return receipt;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ // Review must cover the final code of the final run. The newest receipt for
168
+ // this mission must be a finished run's summary, recorded on a clean folder
169
+ // whose code tree is the one checked out now, with nothing newer in the
170
+ // mission's own state and no commit made after it. Every tick in that run
171
+ // must have passed its check and a verdict from a reviewer engine other than
172
+ // the member and every engine that did the work.
173
+ function reviewHold(mission, workerEngine, cwd, pin = {}) {
174
+ const receipt = latestRunReceipt(mission.id, cwd);
175
+ if (!receipt) return 'no run receipt for this mission could be read';
176
+ const result = receipt.result && typeof receipt.result === 'object' ? receipt.result : {};
177
+ if (result.kind !== 'mission_run_summary') return 'the latest run has not finished';
178
+ const receiptAt = Date.parse(receipt.at || '');
179
+ if (!Number.isFinite(receiptAt)) return 'the latest run receipt has no time';
180
+ if (receipt.clean !== true) return 'the latest run ended with unsaved changes';
181
+ const head = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
182
+ if (!head.ok || !head.stdout || receipt.git_tree !== head.stdout) {
183
+ return 'the latest review was not recorded on the code that is checked out now';
184
+ }
185
+ const commitTime = run('git', ['log', '-1', '--format=%ct', 'HEAD'], cwd);
186
+ const committedAt = Number(commitTime.stdout) * 1000;
187
+ if (!commitTime.ok || !Number.isFinite(committedAt) || committedAt <= 0 || committedAt > receiptAt) {
188
+ return 'the code was committed after the latest review';
189
+ }
190
+
191
+ const ticks = Array.isArray(result.ticks) ? result.ticks : [];
192
+ if (!ticks.length || result.tick_count !== ticks.length) return 'no reviewed work is recorded on the latest run';
193
+ const lastTick = ticks[ticks.length - 1] || {};
194
+ const lastTickAt = Date.parse(mission.last_tick_at || '');
195
+ if (!Number.isFinite(lastTickAt) || lastTickAt > receiptAt) return 'the mission ran again after the latest receipt';
196
+ if (mission.last_tick_status !== 'ran') return 'the mission\'s last step did not finish cleanly';
197
+ if (mission.last_tick_index !== undefined && mission.last_tick_index !== lastTick.tick_index) {
198
+ return 'the mission ran again after the latest receipt';
199
+ }
200
+
201
+ const worker = String(workerEngine || mission.runner || '').trim().toLowerCase();
202
+ if (!worker || worker === 'auto') return 'the engine that did the work is unknown';
203
+ const self = new Set([mission.owner, worker, ...ticks.map((tick) => tick && tick.engine_id)]
204
+ .map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
205
+ for (const tick of ticks) {
206
+ if (!tick || typeof tick !== 'object' || tick.status !== 'ran' || tick.verifier_passed !== true) {
207
+ return 'a step on the latest run did not pass its check';
208
+ }
209
+ const referee = tick.referee_result;
210
+ if (!referee || typeof referee !== 'object' || referee.mode !== 'engine' || referee.passed !== true) {
211
+ return 'a reviewer on the latest run did not pass it';
212
+ }
213
+ const reviewer = String(referee.engine || '').trim().toLowerCase();
214
+ if (!reviewer || self.has(reviewer)) return 'the latest run was reviewed by the same engine that did the work';
215
+ }
216
+ if (lastTick.reviewed_git_tree !== head.stdout) return 'the review did not see the code that is checked out now';
217
+ pin.tree = head.stdout;
218
+ return null;
219
+ }
220
+
221
+ // The checked-out code as git sees it with config tricks switched off: every
222
+ // untracked file listed, submodules compared, no file monitor, and no index
223
+ // entry marked assume-unchanged or skip-worktree (anything but an 'H' tag).
224
+ function gitCodeState(cwd) {
225
+ const status = run('git', [
226
+ '-c', 'status.showUntrackedFiles=all', '-c', 'core.fsmonitor=false',
227
+ 'status', '--porcelain', '--untracked-files=all', '--ignore-submodules=none',
228
+ ], cwd);
229
+ const index = run('git', ['-c', 'core.fsmonitor=false', 'ls-files', '-v'], cwd);
230
+ const hidden = !index.ok || index.stdout.split('\n').some((line) => line && !line.startsWith('H '));
231
+ const tree = run('git', ['rev-parse', 'HEAD^{tree}'], cwd);
232
+ return {
233
+ clean: status.ok && !status.stdout && !hidden,
234
+ hidden,
235
+ status: status.ok ? status.stdout : null,
236
+ tree: tree.ok && tree.stdout ? tree.stdout : null,
237
+ };
238
+ }
239
+
240
+ // New files Atris writes for itself while a run is going (receipts, state,
241
+ // logs). Changed tracked files always count.
242
+ const BOOKKEEPING = /^\?\? (?:\.atris\/|atris\/(?:runs|status|logs)\/|atris\/team\/[^/]+\/logs\/)/;
243
+
244
+ function codeStatus(state) {
245
+ if (!state || state.status == null) return null;
246
+ return state.status.split('\n').filter((line) => line && !BOOKKEEPING.test(line)).join('\n');
247
+ }
248
+
249
+ function unsavedHold(cwd) {
250
+ if (!gitCodeState(cwd).clean) return 'unsaved changes; the check may not match what would merge';
251
+ return null;
252
+ }
253
+
254
+ function held(gate, line) {
255
+ return { merged: false, auto_merge: false, gate, line: `left the branch for you: ${line}` };
256
+ }
257
+
258
+ function autolandMemberRun({ mission, cwd, summary = '', workerEngine = '', loadTask = defaultLoadTask } = {}) {
259
+ if (!mission || typeof mission !== 'object') return held('mission', 'the mission could not be read');
260
+ if (mission.verifier_result?.passed !== true) return held('check', 'the check did not pass');
261
+ if (!policyOn(cwd)) return held('policy', 'self-landing is not switched on in the main checkout');
262
+
263
+ const missionLane = protectedLane({ ...mission, title: mission.objective });
264
+ if (missionLane) return held('protected', `this mission sits in a protected area (${missionLane})`);
265
+ const taskIds = linkedTaskIds(mission);
266
+ if (!taskIds) return held('protected', 'the mission\'s linked tasks are not a clean list of ids');
267
+ for (const id of taskIds) {
268
+ let task = null;
269
+ try {
270
+ task = loadTask(id, cwd);
271
+ } catch {
272
+ task = null;
273
+ }
274
+ if (!task || typeof task !== 'object') return held('protected', `a linked task (${id}) could not be read`);
275
+ const lane = protectedLane(task);
276
+ if (lane) return held('protected', `a linked task sits in a protected area (${lane})`);
277
+ }
278
+
279
+ const unsaved = unsavedHold(cwd);
280
+ if (unsaved) return held('unsaved', unsaved);
281
+ const pin = {};
282
+ const reviewGap = reviewHold(mission, workerEngine, cwd, pin);
283
+ if (reviewGap) return held('review', reviewGap);
284
+ // Pin the commit the review passed on. Only this commit is pushed and
285
+ // merged, so a commit made after this point never rides along.
286
+ const headRead = run('git', ['rev-parse', 'HEAD'], cwd);
287
+ const headSha = headRead.ok ? headRead.stdout : '';
288
+ if (!/^[0-9a-f]{40,64}$/.test(headSha)) return held('branch', 'the latest commit could not be read');
289
+ const pinnedTree = () => {
290
+ const tree = run('git', ['rev-parse', `${headSha}^{tree}`], cwd);
291
+ return tree.ok && tree.stdout === pin.tree;
292
+ };
293
+ if (!pin.tree || !pinnedTree()) return held('review', 'the code moved while the review was being checked');
294
+
295
+ const branchRead = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
296
+ const branch = String(mission.worktree?.branch || branchRead.stdout || '').trim();
297
+ if (!branch || branch === 'HEAD' || branch === 'master' || branch === 'main') {
298
+ return held('branch', 'the work is not on its own branch');
299
+ }
300
+ const remoteHead = run('git', ['ls-remote', '--symref', 'origin', 'HEAD'], cwd);
301
+ const base = (remoteHead.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m) || [])[1] || '';
302
+ if (!remoteHead.ok || !base) return held('base', 'the main branch on GitHub could not be read');
303
+ if (branch === base) return held('branch', 'the work is not on its own branch');
304
+
305
+ if (!pinnedTree()) return held('review', 'the reviewed commit no longer holds the reviewed code');
306
+ const push = run('git', ['push', 'origin', `${headSha}:refs/heads/${branch}`], cwd);
307
+ if (!push.ok) return held('push', `the branch could not be pushed (${push.stderr.split('\n')[0]})`);
308
+
309
+ // Find the open pull request from exactly this branch into the main
310
+ // branch, or open one. Merge is always addressed by number, never by a
311
+ // branch name gh might read as a number, and pinned to the pushed commit.
312
+ const listed = run('gh', [
313
+ 'pr', 'list', '--head', branch, '--base', base, '--state', 'open',
314
+ '--json', 'number,headRefName,baseRefName,headRefOid,isCrossRepository',
315
+ ], cwd);
316
+ if (!listed.ok) return held('pull request', `open pull requests could not be read (${listed.stderr.split('\n')[0]})`);
317
+ let rows;
318
+ try {
319
+ rows = JSON.parse(listed.stdout || '[]');
320
+ } catch {
321
+ rows = null;
322
+ }
323
+ if (!Array.isArray(rows)) return held('pull request', 'open pull requests could not be read');
324
+ const matches = rows.filter((pr) => pr && pr.headRefName === branch && pr.baseRefName === base
325
+ && pr.isCrossRepository === false && Number.isInteger(pr.number) && pr.number > 0);
326
+ if (matches.length > 1) return held('pull request', 'more than one pull request is open for this branch');
327
+ let number = matches.length ? matches[0].number : 0;
328
+ let url = '';
329
+ if (!number) {
330
+ const title = String(mission.objective || branch).replace(/\s+/g, ' ').trim().slice(0, 70);
331
+ const body = String(summary || '').trim() || `Receipt: ${mission.receipt_path || 'none recorded'}`;
332
+ const created = run('gh', ['pr', 'create', '--head', branch, '--base', base, '--title', title, '--body', body], cwd);
333
+ url = created.ok ? created.stdout.split('\n').pop().trim() : '';
334
+ number = Number((url.match(/\/pull\/(\d+)$/) || [])[1] || 0);
335
+ if (!created.ok || !number) {
336
+ return held('pull request', `the pull request could not be opened (${(created.stderr || url).split('\n')[0]})`);
337
+ }
338
+ }
339
+ const label = url || `pull request #${number}`;
340
+
341
+ const merge = run('gh', ['pr', 'merge', String(number), '--auto', '--merge', '--match-head-commit', headSha], cwd);
342
+ if (!merge.ok) return held('auto-merge', `auto-merge could not be switched on for ${label}`);
343
+ return { merged: false, auto_merge: true, gate: null, url: label, line: `merges on its own once checks pass: ${label}` };
344
+ }
345
+
346
+ module.exports = {
347
+ autolandMemberRun,
348
+ codeStatus,
349
+ gitCodeState,
350
+ };
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const os = require('os');
9
9
  const path = require('path');
10
+ const { RUN_TAG, runMarker } = require('./member-progress');
10
11
 
11
12
  const QUIET_AFTER_MS = 2 * 60 * 1000;
12
13
  const HIDE_DONE_AFTER_MS = 60 * 60 * 1000;
@@ -38,7 +39,23 @@ function parseLine(raw) {
38
39
  if (!m) return null;
39
40
  const at = Date.parse(m[1]);
40
41
  if (Number.isNaN(at)) return null;
41
- return { at, text: m[3] };
42
+ return { at, mission: m[2], text: m[3] };
43
+ }
44
+
45
+ // "started: [pid 4242 engine claude] job" -> { pid, engine, job }. Older
46
+ // lines have no tag, so pid stays null and the board cannot say if it lives.
47
+ function parseStarted(text) {
48
+ const body = String(text || '').replace(/^started:\s*/, '');
49
+ const m = RUN_TAG.exec(body);
50
+ if (!m) return { pid: null, engine: '', run: '', job: body };
51
+ return { pid: Number(m[1]), engine: m[2] || '', run: m[3] || '', job: body.slice(m[0].length) };
52
+ }
53
+
54
+ // Signal 0 checks a process exists without touching it. EPERM means it
55
+ // exists but belongs to someone else, which still counts as alive.
56
+ function isAlive(pid) {
57
+ if (!Number.isInteger(pid) || pid <= 0) return false;
58
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
42
59
  }
43
60
 
44
61
  function cut(text, width) {
@@ -46,12 +63,16 @@ function cut(text, width) {
46
63
  return s.length > width ? `${s.slice(0, width - 3)}...` : s;
47
64
  }
48
65
 
49
- function ago(ms) {
66
+ function span(ms) {
50
67
  const s = Math.max(0, Math.floor(ms / 1000));
51
- if (s < 60) return `${s}s ago`;
52
- if (s < 3600) return `${Math.floor(s / 60)}m ago`;
53
- if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
54
- return `${Math.floor(s / 86400)}d ago`;
68
+ if (s < 60) return `${s}s`;
69
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
70
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
71
+ return `${Math.floor(s / 86400)}d`;
72
+ }
73
+
74
+ function ago(ms) {
75
+ return `${span(ms)} ago`;
55
76
  }
56
77
 
57
78
  function escapeRe(s) {
@@ -61,7 +82,7 @@ function escapeRe(s) {
61
82
  // The current run is everything from the latest "started:" line on.
62
83
  // Steps that only write the member's own log are housekeeping, so the last
63
84
  // step column skips them unless nothing else happened.
64
- function memberRow(name, text, now) {
85
+ function currentRun(text) {
65
86
  const lines = text.split('\n').map(parseLine).filter(Boolean);
66
87
  if (!lines.length) return null;
67
88
  let start = 0;
@@ -71,18 +92,35 @@ function memberRow(name, text, now) {
71
92
  const run = lines.slice(start);
72
93
  const startedLine = run[0].text.startsWith('started:') ? run[0] : null;
73
94
  const finished = [...run].reverse().find((l) => l.text.startsWith('finished:'));
95
+ // A stop wins over the runner's own "finished: failed" that may land first.
96
+ const stopped = run.some((l) => /^finished:\s*stopped\b/.test(l.text));
97
+ return { run, startedLine, finished, stopped, meta: parseStarted(startedLine ? startedLine.text : '') };
98
+ }
99
+
100
+ function memberRow(name, text, now) {
101
+ const current = currentRun(text);
102
+ if (!current) return null;
103
+ const { run, startedLine, finished, stopped, meta } = current;
74
104
  const ownLog = new RegExp(`atris/team/${escapeRe(name)}/logs\\b`);
75
105
  const steps = run.filter((l) => !l.text.startsWith('started:') && !l.text.startsWith('finished:'));
76
106
  const real = steps.filter((l) => !ownLog.test(l.text));
77
107
  const step = real[real.length - 1] || steps[steps.length - 1];
78
108
  const last = run[run.length - 1];
109
+ const alive = finished || meta.pid === null ? null : isAlive(meta.pid);
79
110
  let state;
80
- if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
111
+ if (stopped) state = 'stopped';
112
+ else if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
113
+ else if (alive === false) state = 'stopped';
81
114
  else state = now - last.at < QUIET_AFTER_MS ? 'working' : 'quiet';
82
115
  return {
83
116
  member: name,
84
117
  state,
85
- job: startedLine ? cut(startedLine.text.slice('started:'.length), JOB_WIDTH) : '',
118
+ pid: meta.pid,
119
+ engine: meta.engine,
120
+ // null when the started line has no pid; false once the run is over.
121
+ alive: meta.pid === null ? null : alive === true,
122
+ running_for: alive ? span(now - startedLine.at) : '',
123
+ job: startedLine ? cut(meta.job, JOB_WIDTH) : '',
86
124
  step: step ? cut(step.text, STEP_WIDTH) : '',
87
125
  ago: ago(now - last.at),
88
126
  at: last.at,
@@ -107,15 +145,21 @@ function readBoard({ dir = progressDir(), now = Date.now(), all = false } = {})
107
145
  function renderBoard(rows, dir = progressDir(), hidden = 0) {
108
146
  const note = hidden ? `${hidden} finished over an hour ago, hidden. --all shows them` : '';
109
147
  if (!rows.length) return note ? `nobody is working right now. ${note}` : `no member progress yet in ${dir}`;
110
- const w = (key, min) => Math.max(min, ...rows.map((r) => r[key].length));
111
- const nameW = w('member', 6);
112
- const stateW = w('state', 5);
113
- const agoW = w('ago', 3);
114
- const jobW = Math.max(3, ...rows.map((r) => (r.job || '-').length));
115
- const pad = (s, n) => s.padEnd(n);
116
- const line = (a, b, c, d, e) => `${pad(a, nameW)} ${pad(b, stateW)} ${pad(c, agoW)} ${pad(d, jobW)} ${e}`.trimEnd();
117
- const out = [line('member', 'state', 'ago', 'job', 'last step')];
118
- for (const r of rows) out.push(line(r.member, r.state, r.ago, r.job || '-', r.step || '-'));
148
+ const cells = rows.map((r) => [
149
+ r.member,
150
+ r.state,
151
+ r.alive === null ? '-' : (r.alive ? 'yes' : 'no'),
152
+ r.engine || '-',
153
+ r.running_for || '-',
154
+ r.ago,
155
+ r.job || '-',
156
+ r.step || '-',
157
+ ]);
158
+ const head = ['member', 'state', 'alive', 'engine', 'running for', 'ago', 'job', 'last step'];
159
+ const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
160
+ const line = (c) => c.map((v, i) => (i === c.length - 1 ? v : v.padEnd(widths[i]))).join(' ').trimEnd();
161
+ const out = [line(head)];
162
+ for (const c of cells) out.push(line(c));
119
163
  if (note) out.push('', note);
120
164
  return out.join('\n');
121
165
  }
@@ -141,6 +185,11 @@ function progressEvent(member, raw) {
141
185
  if (tagged) {
142
186
  event.kind = tagged[1];
143
187
  event.text = tagged[2];
188
+ if (tagged[1] === 'started') {
189
+ const meta = parseStarted(tagged[2]);
190
+ event.text = meta.job;
191
+ if (meta.pid !== null) { event.pid = meta.pid; event.engine = meta.engine; }
192
+ }
144
193
  if (tagged[1] === 'finished') event.ok = /^ok\b/.test(tagged[2]);
145
194
  }
146
195
  return event;
@@ -167,13 +216,13 @@ function afterLastNewline(file) {
167
216
  } catch { return 0; } finally { fs.closeSync(fd); }
168
217
  }
169
218
 
170
- function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
219
+ function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = false } = {}) {
171
220
  const offsets = new Map();
172
221
  const sizeOf = (file) => { try { return fs.statSync(file).size; } catch { return null; } };
173
222
  const list = () => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch { return []; } };
174
223
  for (const f of list()) offsets.set(f, afterLastNewline(path.join(dir, f)));
175
224
 
176
- function readNew(f) {
225
+ function readNew(f, events) {
177
226
  const file = path.join(dir, f);
178
227
  const size = sizeOf(file);
179
228
  if (size === null) return;
@@ -195,7 +244,7 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
195
244
  const member = f.slice(0, -'.log'.length);
196
245
  for (const raw of buf.subarray(0, end).toString('utf8').split('\n')) {
197
246
  const event = progressEvent(member, raw.replace(/\r$/, ''));
198
- if (event) emit(event);
247
+ if (event) events.push(event);
199
248
  }
200
249
  }
201
250
 
@@ -209,7 +258,10 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
209
258
  watcher.on('error', () => { try { watcher.close(); } catch {} watcher = null; });
210
259
  } catch { watcher = null; }
211
260
  }
212
- for (const f of list()) readNew(f);
261
+ const events = [];
262
+ for (const f of list()) readNew(f, events);
263
+ if (orderByTime) events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
264
+ for (const event of events) emit(event);
213
265
  }
214
266
  const timer = setInterval(scan, pollMs);
215
267
  scan();
@@ -223,6 +275,109 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
223
275
  };
224
276
  }
225
277
 
278
+ // Stop a member's live run: SIGTERM its process group (the engine is spawned
279
+ // detached, so the group is the run and everything it started), wait up to
280
+ // graceMs, then SIGKILL whatever is left. Writes "finished: stopped" so the
281
+ // board stops counting it. Resolves { ok, message } with one plain line.
282
+ function signalRun(pid, sig) {
283
+ try { process.kill(-pid, sig); return; } catch {}
284
+ try { process.kill(pid, sig); } catch {}
285
+ }
286
+
287
+ function runAlive(pid) {
288
+ try { process.kill(-pid, 0); return true; } catch (e) { if (e.code === 'EPERM') return true; }
289
+ return isAlive(pid);
290
+ }
291
+
292
+ // Engines a member run can be, and the program name each one runs as.
293
+ const ENGINE_PROGRAMS = Object.freeze({
294
+ claude: ['claude'], fable: ['claude'], haiku: ['claude'], codex: ['codex'],
295
+ cursor: ['cursor-agent'], composer: ['cursor-agent'], grok: ['grok'],
296
+ devin: ['devin'], agy: ['agy'], opencode: ['opencode'],
297
+ });
298
+
299
+ function readProcess(pid) {
300
+ const run = (fmt) => require('child_process').spawnSync('ps', ['-o', fmt, '-p', String(pid)], {
301
+ encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
302
+ });
303
+ const head = run('pgid=,command=');
304
+ const born = run('lstart=');
305
+ if (head.status !== 0 || born.status !== 0) return null;
306
+ const m = /^\s*(\d+)\s+(.*)$/s.exec(String(head.stdout || '').trim());
307
+ const startedAt = Date.parse(`${String(born.stdout || '').trim()} UTC`);
308
+ return m ? { pgid: Number(m[1]), command: m[2], startedAt: Number.isFinite(startedAt) ? startedAt : null } : null;
309
+ }
310
+
311
+ // Only signal a pid that is, right now, a known engine program leading its
312
+ // own process group, and never this process's own group or a system pid.
313
+ // The engine process must have started within this many ms of the member's
314
+ // own started line, which binds a stop to that exact run.
315
+ const START_WINDOW_MS = 30000;
316
+
317
+ function pidRunsEngine(pid, engine, deps = {}, startedLineAt = null, member = '', run = '') {
318
+ if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
319
+ const key = String(engine || '').trim().toLowerCase();
320
+ if (!Object.prototype.hasOwnProperty.call(ENGINE_PROGRAMS, key)) return false;
321
+ const programs = ENGINE_PROGRAMS[key];
322
+ const info = (deps.readProcess || readProcess)(pid);
323
+ if (!info || info.pgid !== pid) return false;
324
+ if (!Number.isFinite(startedLineAt) || !Number.isFinite(info.startedAt)
325
+ || Math.abs(info.startedAt - startedLineAt) > START_WINDOW_MS) return false;
326
+ const ownGroup = (deps.readProcess || readProcess)(process.pid);
327
+ if (ownGroup && ownGroup.pgid === pid) return false;
328
+ const program = path.basename(String(info.command).trim().split(/\s+/)[0] || '');
329
+ if (!programs.includes(program)) return false;
330
+ // The run token proves the process is this member's run: it is on the first
331
+ // line of the engine's prompt and in no other member's log.
332
+ if (!/^[0-9a-f]{16}$/.test(String(run || '')) || !member) return false;
333
+ // The marker must open the engine's prompt: right after its -p flag, as
334
+ // whole words, so a command that merely mentions it does not count.
335
+ const escaped = runMarker(member, run).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
336
+ // The program, then -p, then the marker: nothing earlier can carry it. ps on
337
+ // macOS prints the prompt's line break as the four characters \\012.
338
+ if (!new RegExp(`^\\S+\\s+-p\\s+${escaped}(?=\\s|\\\\012|\\?|$)`).test(String(info.command))) return false;
339
+ return { startedAt: info.startedAt, command: info.command };
340
+ }
341
+
342
+ async function stopMember(name, { dir = progressDir(), graceMs = 10000, pollMs = 100, now = () => Date.now(), deps = {} } = {}) {
343
+ const nothing = { ok: false, message: 'nothing running' };
344
+ const member = String(name || '').trim();
345
+ if (!member || !/^[A-Za-z0-9._-]+$/.test(member)) return nothing;
346
+ const file = path.join(dir, `${member}.log`);
347
+ let text = '';
348
+ try { text = readTail(file); } catch { return nothing; }
349
+ const current = currentRun(text);
350
+ if (!current || current.finished || !current.startedLine) return nothing;
351
+ const { pid, engine, run } = current.meta;
352
+ if (pid === null || !runAlive(pid)) return nothing;
353
+ // A pid from a run that died without a finished line can be reused by an
354
+ // unrelated program; only signal it while its command is still the engine.
355
+ const identity = pidRunsEngine(pid, engine, deps, current.startedLine.at, member, run);
356
+ if (!identity) return nothing;
357
+
358
+ const startedAt = current.startedLine.at;
359
+ signalRun(pid, 'SIGTERM');
360
+ const deadline = now() + graceMs;
361
+ // Poll on timers, not a busy loop, so a test parent can reap its child.
362
+ while (runAlive(pid) && now() < deadline) await new Promise((r) => setTimeout(r, pollMs));
363
+ // Check identity again before the hard kill: the pid may have been reused.
364
+ // Before the hard kill, the process must be the very same one: same start
365
+ // time and same command, token included.
366
+ const again = runAlive(pid) && pidRunsEngine(pid, engine, deps, current.startedLine.at, member, run);
367
+ const forced = Boolean(again) && again.startedAt === identity.startedAt && again.command === identity.command;
368
+ if (forced) signalRun(pid, 'SIGKILL');
369
+
370
+ const mission = current.startedLine.mission || '-';
371
+ try { fs.appendFileSync(file, `${new Date().toISOString()} ${mission} finished: stopped\n`, 'utf8'); } catch {}
372
+ const how = forced ? ', it had to be forced' : '';
373
+ return { ok: true, pid, forced, message: `stopped ${member} after ${span(now() - startedAt)} of work${how}` };
374
+ }
375
+
376
+ async function memberStopCommand(name) {
377
+ const result = await stopMember(name);
378
+ console.log(result.message);
379
+ }
380
+
226
381
  function memberBoardCommand(...args) {
227
382
  const asJson = args.includes('--json');
228
383
  const all = args.includes('--all');
@@ -260,4 +415,7 @@ function memberBoardCommand(...args) {
260
415
  });
261
416
  }
262
417
 
263
- module.exports = { readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand };
418
+ module.exports = {
419
+ readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand,
420
+ parseStarted, isAlive, stopMember, memberStopCommand,
421
+ };