atris 3.68.9 → 3.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/atris/team/opus-overnight/MEMBER.md +1 -0
- package/commands/init.js +9 -0
- package/commands/member.js +436 -40
- package/commands/mission.js +158 -12
- package/commands/sync.js +13 -0
- package/commands/task.js +63 -1
- package/commands/worktree.js +42 -5
- package/lib/auto-accept-certified.js +1 -0
- package/lib/engine-ask.js +2 -1
- package/lib/engine-registry.js +5 -4
- package/lib/engine-validate.js +189 -9
- package/lib/gitignore.js +56 -0
- package/lib/member-autoland.js +442 -0
- package/lib/member-board.js +257 -29
- package/lib/member-progress.js +68 -4
- package/lib/member-report.js +314 -0
- package/lib/member-scaffold.js +1 -2
- package/lib/member-switches.js +7 -0
- package/lib/member-task-pick.js +116 -0
- package/lib/member-tidy.js +235 -0
- package/lib/member-up.js +286 -0
- package/lib/member-verbs.js +84 -0
- package/lib/runner-command.js +29 -0
- package/lib/task-db.js +21 -1
- package/package.json +1 -1
package/lib/member-board.js
CHANGED
|
@@ -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
|
|
66
|
+
function span(ms) {
|
|
50
67
|
const s = Math.max(0, Math.floor(ms / 1000));
|
|
51
|
-
if (s < 60) return `${s}s
|
|
52
|
-
if (s < 3600) return `${Math.floor(s / 60)}m
|
|
53
|
-
if (s < 86400) return `${Math.floor(s / 3600)}h
|
|
54
|
-
return `${Math.floor(s / 86400)}d
|
|
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
|
|
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,59 @@ 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
|
+
// How the member's most recent finished run ended: ok, failed, or stopped.
|
|
101
|
+
// Looks past the current run, so a member busy again still shows how its
|
|
102
|
+
// last job went. A stop wins over a "finished: failed" in the same run.
|
|
103
|
+
function lastResult(text) {
|
|
104
|
+
const lines = text.split('\n').map(parseLine).filter(Boolean);
|
|
105
|
+
let end = -1;
|
|
106
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
107
|
+
if (lines[i].text.startsWith('finished:')) { end = i; break; }
|
|
108
|
+
}
|
|
109
|
+
if (end < 0) return null;
|
|
110
|
+
let start = 0;
|
|
111
|
+
for (let i = end; i >= 0; i--) {
|
|
112
|
+
if (lines[i].text.startsWith('started:')) { start = i; break; }
|
|
113
|
+
}
|
|
114
|
+
const finishes = lines.slice(start, end + 1).filter((l) => l.text.startsWith('finished:'));
|
|
115
|
+
const stop = finishes.find((l) => /^finished:\s*stopped\b/.test(l.text));
|
|
116
|
+
const line = stop || lines[end];
|
|
117
|
+
const word = /^finished:\s*(\S*)/.exec(line.text)[1];
|
|
118
|
+
return { result: word || 'done', at: line.at };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function memberRow(name, text, now) {
|
|
122
|
+
const current = currentRun(text);
|
|
123
|
+
if (!current) return null;
|
|
124
|
+
const { run, startedLine, finished, stopped, meta } = current;
|
|
74
125
|
const ownLog = new RegExp(`atris/team/${escapeRe(name)}/logs\\b`);
|
|
75
126
|
const steps = run.filter((l) => !l.text.startsWith('started:') && !l.text.startsWith('finished:'));
|
|
76
127
|
const real = steps.filter((l) => !ownLog.test(l.text));
|
|
77
128
|
const step = real[real.length - 1] || steps[steps.length - 1];
|
|
78
129
|
const last = run[run.length - 1];
|
|
130
|
+
const alive = finished || meta.pid === null ? null : isAlive(meta.pid);
|
|
79
131
|
let state;
|
|
80
|
-
if (
|
|
132
|
+
if (stopped) state = 'stopped';
|
|
133
|
+
else if (finished) state = /^finished:\s*ok\b/.test(finished.text) ? 'done ok' : 'done failed';
|
|
134
|
+
else if (alive === false) state = 'stopped';
|
|
81
135
|
else state = now - last.at < QUIET_AFTER_MS ? 'working' : 'quiet';
|
|
136
|
+
const result = lastResult(text);
|
|
82
137
|
return {
|
|
83
138
|
member: name,
|
|
84
139
|
state,
|
|
85
|
-
|
|
140
|
+
last_result: result ? result.result : '',
|
|
141
|
+
last_result_ago: result ? ago(now - result.at) : '',
|
|
142
|
+
pid: meta.pid,
|
|
143
|
+
engine: meta.engine,
|
|
144
|
+
// null when the started line has no pid; false once the run is over.
|
|
145
|
+
alive: meta.pid === null ? null : alive === true,
|
|
146
|
+
running_for: alive ? span(now - startedLine.at) : '',
|
|
147
|
+
job: startedLine ? cut(meta.job, JOB_WIDTH) : '',
|
|
86
148
|
step: step ? cut(step.text, STEP_WIDTH) : '',
|
|
87
149
|
ago: ago(now - last.at),
|
|
88
150
|
at: last.at,
|
|
@@ -91,31 +153,74 @@ function memberRow(name, text, now) {
|
|
|
91
153
|
};
|
|
92
154
|
}
|
|
93
155
|
|
|
94
|
-
|
|
156
|
+
// A member put to sleep says so on the board, even with no progress log.
|
|
157
|
+
// Sleep does not end a run already going, so that run reads as finishing.
|
|
158
|
+
function markAsleep(row) {
|
|
159
|
+
const live = (row.state === 'working' || row.state === 'quiet') && row.alive !== false;
|
|
160
|
+
return { ...row, state: live ? 'asleep after this run' : 'asleep', asleep: true, idle: false };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function asleepRow(member) {
|
|
164
|
+
return {
|
|
165
|
+
member, state: 'asleep', last_result: '', last_result_ago: '', asleep: true, pid: null, engine: '', alive: null,
|
|
166
|
+
running_for: '', job: '', step: '', ago: '-', at: 0, idle: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The progress folder is shared by every project on this computer. `only`
|
|
171
|
+
// names the members to show; null shows every log.
|
|
172
|
+
function readBoard({ dir = progressDir(), now = Date.now(), all = false, asleep = [], only = null } = {}) {
|
|
95
173
|
let files = [];
|
|
96
174
|
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch {}
|
|
175
|
+
if (only) files = files.filter((f) => only.includes(f.slice(0, -'.log'.length)));
|
|
176
|
+
const sleeping = new Set(asleep);
|
|
97
177
|
const rows = [];
|
|
98
178
|
for (const f of files) {
|
|
99
179
|
let text = '';
|
|
100
180
|
try { text = readTail(path.join(dir, f)); } catch { continue; }
|
|
101
181
|
const row = memberRow(f.slice(0, -'.log'.length), text, now);
|
|
102
|
-
if (row) rows.push(row);
|
|
182
|
+
if (row) rows.push(sleeping.has(row.member) ? markAsleep(row) : { ...row, asleep: false });
|
|
183
|
+
}
|
|
184
|
+
for (const name of sleeping) {
|
|
185
|
+
if (!rows.some((r) => r.member === name)) rows.push(asleepRow(name));
|
|
103
186
|
}
|
|
104
187
|
return rows.filter((r) => all || !r.idle).sort((a, b) => b.at - a.at);
|
|
105
188
|
}
|
|
106
189
|
|
|
190
|
+
// Members of this project: one folder each under atris/team. Outside an
|
|
191
|
+
// Atris project there is no team, so the board shows every log (null).
|
|
192
|
+
function teamHere(root = process.cwd()) {
|
|
193
|
+
try {
|
|
194
|
+
return fs.readdirSync(path.join(root, 'atris', 'team'), { withFileTypes: true })
|
|
195
|
+
.filter((e) => e.isDirectory() && !/^[._]/.test(e.name))
|
|
196
|
+
.map((e) => e.name);
|
|
197
|
+
} catch { return null; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Members the sleep switch has off in this workspace.
|
|
201
|
+
function sleepingHere() {
|
|
202
|
+
try { return require('./member-switches').asleepMembers(process.cwd()); } catch { return []; }
|
|
203
|
+
}
|
|
204
|
+
|
|
107
205
|
function renderBoard(rows, dir = progressDir(), hidden = 0) {
|
|
108
206
|
const note = hidden ? `${hidden} finished over an hour ago, hidden. --all shows them` : '';
|
|
109
207
|
if (!rows.length) return note ? `nobody is working right now. ${note}` : `no member progress yet in ${dir}`;
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
208
|
+
const cells = rows.map((r) => [
|
|
209
|
+
r.member,
|
|
210
|
+
r.state,
|
|
211
|
+
r.last_result ? `${r.last_result} ${r.last_result_ago}` : '-',
|
|
212
|
+
r.alive === null ? '-' : (r.alive ? 'yes' : 'no'),
|
|
213
|
+
r.engine || '-',
|
|
214
|
+
r.running_for || '-',
|
|
215
|
+
r.ago,
|
|
216
|
+
r.job || '-',
|
|
217
|
+
r.step || '-',
|
|
218
|
+
]);
|
|
219
|
+
const head = ['member', 'state', 'last result', 'alive', 'engine', 'running for', 'ago', 'job', 'last step'];
|
|
220
|
+
const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
|
|
221
|
+
const line = (c) => c.map((v, i) => (i === c.length - 1 ? v : v.padEnd(widths[i]))).join(' ').trimEnd();
|
|
222
|
+
const out = [line(head)];
|
|
223
|
+
for (const c of cells) out.push(line(c));
|
|
119
224
|
if (note) out.push('', note);
|
|
120
225
|
return out.join('\n');
|
|
121
226
|
}
|
|
@@ -141,6 +246,11 @@ function progressEvent(member, raw) {
|
|
|
141
246
|
if (tagged) {
|
|
142
247
|
event.kind = tagged[1];
|
|
143
248
|
event.text = tagged[2];
|
|
249
|
+
if (tagged[1] === 'started') {
|
|
250
|
+
const meta = parseStarted(tagged[2]);
|
|
251
|
+
event.text = meta.job;
|
|
252
|
+
if (meta.pid !== null) { event.pid = meta.pid; event.engine = meta.engine; }
|
|
253
|
+
}
|
|
144
254
|
if (tagged[1] === 'finished') event.ok = /^ok\b/.test(tagged[2]);
|
|
145
255
|
}
|
|
146
256
|
return event;
|
|
@@ -167,13 +277,13 @@ function afterLastNewline(file) {
|
|
|
167
277
|
} catch { return 0; } finally { fs.closeSync(fd); }
|
|
168
278
|
}
|
|
169
279
|
|
|
170
|
-
function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
|
|
280
|
+
function followBoard({ dir = progressDir(), emit, pollMs = 1000, orderByTime = false } = {}) {
|
|
171
281
|
const offsets = new Map();
|
|
172
282
|
const sizeOf = (file) => { try { return fs.statSync(file).size; } catch { return null; } };
|
|
173
283
|
const list = () => { try { return fs.readdirSync(dir).filter((f) => f.endsWith('.log')); } catch { return []; } };
|
|
174
284
|
for (const f of list()) offsets.set(f, afterLastNewline(path.join(dir, f)));
|
|
175
285
|
|
|
176
|
-
function readNew(f) {
|
|
286
|
+
function readNew(f, events) {
|
|
177
287
|
const file = path.join(dir, f);
|
|
178
288
|
const size = sizeOf(file);
|
|
179
289
|
if (size === null) return;
|
|
@@ -195,7 +305,7 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
|
|
|
195
305
|
const member = f.slice(0, -'.log'.length);
|
|
196
306
|
for (const raw of buf.subarray(0, end).toString('utf8').split('\n')) {
|
|
197
307
|
const event = progressEvent(member, raw.replace(/\r$/, ''));
|
|
198
|
-
if (event)
|
|
308
|
+
if (event) events.push(event);
|
|
199
309
|
}
|
|
200
310
|
}
|
|
201
311
|
|
|
@@ -209,7 +319,10 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
|
|
|
209
319
|
watcher.on('error', () => { try { watcher.close(); } catch {} watcher = null; });
|
|
210
320
|
} catch { watcher = null; }
|
|
211
321
|
}
|
|
212
|
-
|
|
322
|
+
const events = [];
|
|
323
|
+
for (const f of list()) readNew(f, events);
|
|
324
|
+
if (orderByTime) events.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
325
|
+
for (const event of events) emit(event);
|
|
213
326
|
}
|
|
214
327
|
const timer = setInterval(scan, pollMs);
|
|
215
328
|
scan();
|
|
@@ -223,14 +336,126 @@ function followBoard({ dir = progressDir(), emit, pollMs = 1000 } = {}) {
|
|
|
223
336
|
};
|
|
224
337
|
}
|
|
225
338
|
|
|
339
|
+
// Stop a member's live run: SIGTERM its process group (the engine is spawned
|
|
340
|
+
// detached, so the group is the run and everything it started), wait up to
|
|
341
|
+
// graceMs, then SIGKILL whatever is left. Writes "finished: stopped" so the
|
|
342
|
+
// board stops counting it. Resolves { ok, message } with one plain line.
|
|
343
|
+
function signalRun(pid, sig) {
|
|
344
|
+
try { process.kill(-pid, sig); return; } catch {}
|
|
345
|
+
try { process.kill(pid, sig); } catch {}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function runAlive(pid) {
|
|
349
|
+
try { process.kill(-pid, 0); return true; } catch (e) { if (e.code === 'EPERM') return true; }
|
|
350
|
+
return isAlive(pid);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Engines a member run can be, and the program name each one runs as.
|
|
354
|
+
const ENGINE_PROGRAMS = Object.freeze({
|
|
355
|
+
claude: ['claude'], fable: ['claude'], haiku: ['claude'], codex: ['codex'],
|
|
356
|
+
cursor: ['cursor-agent'], composer: ['cursor-agent'], grok: ['grok'],
|
|
357
|
+
devin: ['devin'], agy: ['agy'], opencode: ['opencode'],
|
|
358
|
+
});
|
|
359
|
+
// Engines whose member run may be led by the login shell that launched them.
|
|
360
|
+
const SHELL_LAUNCHED = Object.freeze(['codex']);
|
|
361
|
+
|
|
362
|
+
function readProcess(pid) {
|
|
363
|
+
const run = (fmt) => require('child_process').spawnSync('ps', ['-o', fmt, '-p', String(pid)], {
|
|
364
|
+
encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
|
365
|
+
});
|
|
366
|
+
const head = run('pgid=,command=');
|
|
367
|
+
const born = run('lstart=');
|
|
368
|
+
if (head.status !== 0 || born.status !== 0) return null;
|
|
369
|
+
const m = /^\s*(\d+)\s+(.*)$/s.exec(String(head.stdout || '').trim());
|
|
370
|
+
const startedAt = Date.parse(`${String(born.stdout || '').trim()} UTC`);
|
|
371
|
+
return m ? { pgid: Number(m[1]), command: m[2], startedAt: Number.isFinite(startedAt) ? startedAt : null } : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Only signal a pid that is, right now, a known engine program leading its
|
|
375
|
+
// own process group, and never this process's own group or a system pid.
|
|
376
|
+
// The engine process must have started within this many ms of the member's
|
|
377
|
+
// own started line, which binds a stop to that exact run.
|
|
378
|
+
const START_WINDOW_MS = 30000;
|
|
379
|
+
|
|
380
|
+
function pidRunsEngine(pid, engine, deps = {}, startedLineAt = null, member = '', run = '') {
|
|
381
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false;
|
|
382
|
+
const key = String(engine || '').trim().toLowerCase();
|
|
383
|
+
if (!Object.prototype.hasOwnProperty.call(ENGINE_PROGRAMS, key)) return false;
|
|
384
|
+
const programs = ENGINE_PROGRAMS[key];
|
|
385
|
+
const info = (deps.readProcess || readProcess)(pid);
|
|
386
|
+
if (!info || info.pgid !== pid) return false;
|
|
387
|
+
if (!Number.isFinite(startedLineAt) || !Number.isFinite(info.startedAt)
|
|
388
|
+
|| Math.abs(info.startedAt - startedLineAt) > START_WINDOW_MS) return false;
|
|
389
|
+
const ownGroup = (deps.readProcess || readProcess)(process.pid);
|
|
390
|
+
if (ownGroup && ownGroup.pgid === pid) return false;
|
|
391
|
+
const program = path.basename(String(info.command).trim().split(/\s+/)[0] || '');
|
|
392
|
+
// The codex mission runner leads its group with a login shell whose script
|
|
393
|
+
// opens with the marker as a no-op: sh -lc ': atris-run <member> <run>; ...'.
|
|
394
|
+
const viaShell = SHELL_LAUNCHED.includes(key) && program === 'sh';
|
|
395
|
+
if (!programs.includes(program) && !viaShell) return false;
|
|
396
|
+
// The run token proves the process is this member's run: it is on the first
|
|
397
|
+
// line of the engine's prompt and in no other member's log.
|
|
398
|
+
if (!/^[0-9a-f]{16}$/.test(String(run || '')) || !member) return false;
|
|
399
|
+
// The marker must open the engine's prompt: right after its -p flag, as
|
|
400
|
+
// whole words, so a command that merely mentions it does not count.
|
|
401
|
+
const escaped = runMarker(member, run).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
402
|
+
// The program, then -p, then the marker: nothing earlier can carry it. ps on
|
|
403
|
+
// macOS prints the prompt's line break as the four characters \\012.
|
|
404
|
+
const shape = viaShell
|
|
405
|
+
? `^\\S+\\s+-lc\\s+:\\s+${escaped};`
|
|
406
|
+
: `^\\S+\\s+-p\\s+${escaped}(?=\\s|\\\\012|\\?|$)`;
|
|
407
|
+
if (!new RegExp(shape).test(String(info.command))) return false;
|
|
408
|
+
return { startedAt: info.startedAt, command: info.command };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function stopMember(name, { dir = progressDir(), graceMs = 10000, pollMs = 100, now = () => Date.now(), deps = {} } = {}) {
|
|
412
|
+
const nothing = { ok: false, message: 'nothing running' };
|
|
413
|
+
const member = String(name || '').trim();
|
|
414
|
+
if (!member || !/^[A-Za-z0-9._-]+$/.test(member)) return nothing;
|
|
415
|
+
const file = path.join(dir, `${member}.log`);
|
|
416
|
+
let text = '';
|
|
417
|
+
try { text = readTail(file); } catch { return nothing; }
|
|
418
|
+
const current = currentRun(text);
|
|
419
|
+
if (!current || current.finished || !current.startedLine) return nothing;
|
|
420
|
+
const { pid, engine, run } = current.meta;
|
|
421
|
+
if (pid === null || !runAlive(pid)) return nothing;
|
|
422
|
+
// A pid from a run that died without a finished line can be reused by an
|
|
423
|
+
// unrelated program; only signal it while its command is still the engine.
|
|
424
|
+
const identity = pidRunsEngine(pid, engine, deps, current.startedLine.at, member, run);
|
|
425
|
+
if (!identity) return nothing;
|
|
426
|
+
|
|
427
|
+
const startedAt = current.startedLine.at;
|
|
428
|
+
signalRun(pid, 'SIGTERM');
|
|
429
|
+
const deadline = now() + graceMs;
|
|
430
|
+
// Poll on timers, not a busy loop, so a test parent can reap its child.
|
|
431
|
+
while (runAlive(pid) && now() < deadline) await new Promise((r) => setTimeout(r, pollMs));
|
|
432
|
+
// Check identity again before the hard kill: the pid may have been reused.
|
|
433
|
+
// Before the hard kill, the process must be the very same one: same start
|
|
434
|
+
// time and same command, token included.
|
|
435
|
+
const again = runAlive(pid) && pidRunsEngine(pid, engine, deps, current.startedLine.at, member, run);
|
|
436
|
+
const forced = Boolean(again) && again.startedAt === identity.startedAt && again.command === identity.command;
|
|
437
|
+
if (forced) signalRun(pid, 'SIGKILL');
|
|
438
|
+
|
|
439
|
+
const mission = current.startedLine.mission || '-';
|
|
440
|
+
try { fs.appendFileSync(file, `${new Date().toISOString()} ${mission} finished: stopped\n`, 'utf8'); } catch {}
|
|
441
|
+
const how = forced ? ', it had to be forced' : '';
|
|
442
|
+
return { ok: true, pid, forced, message: `stopped ${member} after ${span(now() - startedAt)} of work${how}` };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function memberStopCommand(name) {
|
|
446
|
+
const result = await stopMember(name);
|
|
447
|
+
console.log(result.message);
|
|
448
|
+
}
|
|
449
|
+
|
|
226
450
|
function memberBoardCommand(...args) {
|
|
227
451
|
const asJson = args.includes('--json');
|
|
228
452
|
const all = args.includes('--all');
|
|
453
|
+
const only = teamHere();
|
|
229
454
|
if (args.includes('--follow')) {
|
|
230
455
|
const dir = progressDir();
|
|
231
456
|
const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
232
|
-
write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all }) });
|
|
233
|
-
const follower = followBoard({ dir, emit: write });
|
|
457
|
+
write({ kind: 'snapshot', at: new Date().toISOString(), dir, members: readBoard({ dir, all, asleep: sleepingHere(), only }) });
|
|
458
|
+
const follower = followBoard({ dir, emit: (event) => { if (!only || only.includes(event.member)) write(event); } });
|
|
234
459
|
// Runs until ctrl-c or the reader closes the pipe.
|
|
235
460
|
return new Promise((resolve) => {
|
|
236
461
|
const done = () => { follower.stop(); resolve(); };
|
|
@@ -239,11 +464,11 @@ function memberBoardCommand(...args) {
|
|
|
239
464
|
});
|
|
240
465
|
}
|
|
241
466
|
if (asJson) {
|
|
242
|
-
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all }) }, null, 2));
|
|
467
|
+
console.log(JSON.stringify({ ok: true, dir: progressDir(), members: readBoard({ all, asleep: sleepingHere(), only }) }, null, 2));
|
|
243
468
|
return;
|
|
244
469
|
}
|
|
245
470
|
const draw = () => {
|
|
246
|
-
const every = readBoard({ all: true });
|
|
471
|
+
const every = readBoard({ all: true, asleep: sleepingHere(), only });
|
|
247
472
|
const rows = all ? every : every.filter((r) => !r.idle);
|
|
248
473
|
return renderBoard(rows, progressDir(), every.length - rows.length);
|
|
249
474
|
};
|
|
@@ -260,4 +485,7 @@ function memberBoardCommand(...args) {
|
|
|
260
485
|
});
|
|
261
486
|
}
|
|
262
487
|
|
|
263
|
-
module.exports = {
|
|
488
|
+
module.exports = {
|
|
489
|
+
readBoard, renderBoard, memberRow, ago, progressEvent, followBoard, memberBoardCommand,
|
|
490
|
+
parseStarted, teamHere, isAlive, stopMember, memberStopCommand,
|
|
491
|
+
};
|
package/lib/member-progress.js
CHANGED
|
@@ -50,6 +50,45 @@ function formatToolStep(block, cwd) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Codex wraps each command in a login shell ("/bin/zsh -lc 'grep -n x a.txt'");
|
|
54
|
+
// the line shows only what ran inside it.
|
|
55
|
+
function unwrapShellCommand(command) {
|
|
56
|
+
const text = String(command || '').trim();
|
|
57
|
+
const match = text.match(/^(?:\S*\/)?(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1$/);
|
|
58
|
+
return match ? match[2] : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function searchPattern(command) {
|
|
62
|
+
const words = command.split(/\s+/).slice(1);
|
|
63
|
+
const word = words.find((w) => w && !w.startsWith('-')) || '';
|
|
64
|
+
return word.replace(/^(['"])(.*)\1$/, '$2');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One plain line for a codex `exec --json` event, or null when the event is
|
|
68
|
+
// not a step (messages, reasoning, turn bookkeeping). Commands show when they
|
|
69
|
+
// start; file edits only arrive once done.
|
|
70
|
+
function formatCodexEvent(event, cwd) {
|
|
71
|
+
if (!event || (event.type !== 'item.started' && event.type !== 'item.completed')) return null;
|
|
72
|
+
const item = event.item || {};
|
|
73
|
+
switch (item.type) {
|
|
74
|
+
case 'command_execution': {
|
|
75
|
+
const first = unwrapShellCommand(item.command).split('\n')[0].trim();
|
|
76
|
+
if (/^(rg|grep)\s/.test(first)) return `searching for ${searchPattern(first)}`;
|
|
77
|
+
return `running: ${first.slice(0, 70)}`;
|
|
78
|
+
}
|
|
79
|
+
case 'file_change': {
|
|
80
|
+
const files = (Array.isArray(item.changes) ? item.changes : [])
|
|
81
|
+
.map((change) => relativeTo(cwd, change && change.path))
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
return files.length ? `editing ${files.join(', ')}` : null;
|
|
84
|
+
}
|
|
85
|
+
case 'web_search':
|
|
86
|
+
return `searching for ${item.query || ''}`;
|
|
87
|
+
default:
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
function progressDir() {
|
|
54
93
|
return process.env.ATRIS_PROGRESS_DIR || path.join(os.homedir(), '.atris', 'progress');
|
|
55
94
|
}
|
|
@@ -62,13 +101,38 @@ function hhmm(date = new Date()) {
|
|
|
62
101
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
63
102
|
}
|
|
64
103
|
|
|
104
|
+
// A started line can name the run's process and engine up front:
|
|
105
|
+
// "started: [pid 4242 engine claude] <objective>". The board reads the pid to
|
|
106
|
+
// tell a live run from a dead one, and `atris member stop` kills its group.
|
|
107
|
+
const RUN_TAG = /^\[pid (\d+)(?: engine (\S+))?(?: run ([0-9a-f]{16}))?\]\s*/;
|
|
108
|
+
|
|
109
|
+
function startedLine(objective, { pid, engine, run } = {}) {
|
|
110
|
+
const runPart = /^[0-9a-f]{16}$/.test(String(run || '')) ? ` run ${run}` : '';
|
|
111
|
+
const tag = Number.isInteger(pid) && pid > 0 ? `[pid ${pid}${engine ? ` engine ${engine}` : ''}${runPart}] ` : '';
|
|
112
|
+
return `started: ${tag}${objective || ''}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A one-time token for one engine run. It goes on the first line of the
|
|
116
|
+
// engine's prompt (visible in its command line) and in the started line, so
|
|
117
|
+
// member stop can prove a live process is that member's run.
|
|
118
|
+
function newRunToken() {
|
|
119
|
+
return require('crypto').randomBytes(8).toString('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function runMarker(member, token) {
|
|
123
|
+
return `atris-run ${member} ${token}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
65
126
|
// One progress line stays one short line: a 'started:' objective keeps its
|
|
66
|
-
// first line and at most 80 chars
|
|
127
|
+
// first line and at most 80 chars (the run tag does not count); any other
|
|
128
|
+
// line caps at 100.
|
|
67
129
|
function shortenLine(line) {
|
|
68
130
|
const first = String(line || '').split('\n')[0];
|
|
69
131
|
if (first.startsWith('started: ')) {
|
|
70
|
-
const
|
|
71
|
-
|
|
132
|
+
const body = first.slice('started: '.length);
|
|
133
|
+
const tag = (RUN_TAG.exec(body) || [''])[0];
|
|
134
|
+
const objective = body.slice(tag.length);
|
|
135
|
+
return `started: ${tag}${objective.length > 80 ? `${objective.slice(0, 80)}...` : objective}`;
|
|
72
136
|
}
|
|
73
137
|
return first.length > 100 ? `${first.slice(0, 100)}...` : first;
|
|
74
138
|
}
|
|
@@ -91,4 +155,4 @@ function reportProgress(member, line, missionId) {
|
|
|
91
155
|
appendProgress(member, short, missionId);
|
|
92
156
|
}
|
|
93
157
|
|
|
94
|
-
module.exports = { formatToolStep, appendProgress, reportProgress, shortenLine };
|
|
158
|
+
module.exports = { formatToolStep, formatCodexEvent, appendProgress, reportProgress, shortenLine, startedLine, RUN_TAG, newRunToken, runMarker };
|