atris 3.68.9 → 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.
@@ -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
+ };
@@ -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; any other line caps at 100.
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 objective = first.slice('started: '.length);
71
- return `started: ${objective.length > 80 ? `${objective.slice(0, 80)}...` : objective}`;
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 };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // The next real job for a member with no idea given: the oldest open,
4
+ // unclaimed task in this workspace whose job (build, review, search) matches
5
+ // the member's, held neither for a human nor in a protected lane. Callers
6
+ // fall back to the member's own goal when this returns null, unless they
7
+ // ask for requireCheck: then only tasks with a runnable check count, and
8
+ // nothing found means the member stays idle.
9
+
10
+ const { autoJobForMember } = require('./member-engine');
11
+ const { deniedTagOf, declaredProtectedLane, sniffedProtectedLane, isAutoCertifyVerifyCommandAllowed } = require('./auto-accept-certified');
12
+ const { isDecisionHoldTag, isDecisionTask, taskTagTokens } = require('./task-decision');
13
+
14
+ // A task's job uses the member rule with its tags in the name slot and its
15
+ // title in the role slot: tags decide first, then the title, else build.
16
+ function jobForTask(task) {
17
+ return autoJobForMember(taskTagTokens(task).join(' '), task && task.title);
18
+ }
19
+
20
+ function taskBody(task) {
21
+ const meta = (task && task.metadata) || {};
22
+ return ['objective', 'body', 'description', 'details']
23
+ .map((key) => (typeof meta[key] === 'string' ? meta[key].trim() : ''))
24
+ .find(Boolean) || '';
25
+ }
26
+
27
+ // The objective a member run gets: the title, then the body when there is one,
28
+ // on one line. Mission start refuses objectives with line breaks.
29
+ function taskObjective(task) {
30
+ const oneLine = (text) => String(text || '').replace(/\s+/g, ' ').trim();
31
+ const title = oneLine(task && task.title);
32
+ const body = oneLine(taskBody(task));
33
+ if (!body) return title;
34
+ return /[.!?]$/.test(title) ? `${title} ${body}` : `${title}. ${body}`;
35
+ }
36
+
37
+ // Held for a person: a denied tag on the row or in its metadata, a protected
38
+ // lane declared anywhere in its text, or lane terms in its title or body.
39
+ // Tags written in metadata, which the shared tag parser does not read.
40
+ function metadataTags(task) {
41
+ const meta = (task && task.metadata) || {};
42
+ return [meta.tag, ...(Array.isArray(meta.tags) ? meta.tags : [])]
43
+ .filter((t) => typeof t === 'string' && t.trim())
44
+ .map((t) => t.trim().toLowerCase().replace(/^#/, ''));
45
+ }
46
+
47
+ function protectedTask(task) {
48
+ const meta = (task && task.metadata) || {};
49
+ const tags = [...metadataTags(task), ...taskTagTokens(task)];
50
+ if (deniedTagOf(task) || tags.some((tag) => deniedTagOf({ tag }))) return true;
51
+ // Every text field, not just the one the objective uses.
52
+ const allText = ['objective', 'body', 'description', 'details']
53
+ .map((key) => (typeof meta[key] === 'string' ? meta[key] : ''))
54
+ .filter(Boolean)
55
+ .join('\n');
56
+ const probe = { title: task.title, objective: allText, metadata: meta };
57
+ return Boolean(declaredProtectedLane(probe) || sniffedProtectedLane(probe));
58
+ }
59
+
60
+ // The check a task requires, when it names one. A check written as prose
61
+ // that cannot be run means the task cannot be proven here.
62
+ function taskCheck(task) {
63
+ const verify = task && task.metadata && typeof task.metadata.verify === 'string'
64
+ ? task.metadata.verify.trim() : '';
65
+ if (!verify) return { command: '', runnable: true };
66
+ return { command: verify, runnable: isAutoCertifyVerifyCommandAllowed(verify) };
67
+ }
68
+
69
+ function skipReason(task, job, { requireCheck = false } = {}) {
70
+ if (!task || task.status !== 'open') return 'not_open';
71
+ if (task.claimed_by) return 'claimed';
72
+ if (isDecisionTask(task) || metadataTags(task).some(isDecisionHoldTag)) return 'needs_human';
73
+ if (protectedTask(task)) return 'protected_lane';
74
+ const check = taskCheck(task);
75
+ if (!check.runnable) return 'check_not_runnable';
76
+ if (requireCheck && !check.command) return 'no_check';
77
+ if (jobForTask(task) !== job) return 'other_job';
78
+ return '';
79
+ }
80
+
81
+ function fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
82
+ return taskDb.listTasks(db, { workspaceRoot, status: 'open' })
83
+ .filter((task) => !skipReason(task, job, { requireCheck }))
84
+ .sort((a, b) => (a.created_at - b.created_at) || String(a.id).localeCompare(String(b.id)));
85
+ }
86
+
87
+ function pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck = false }) {
88
+ return fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })[0] || null;
89
+ }
90
+
91
+ // Claim the oldest fitting task for the member. A lost race moves on to the
92
+ // next candidate; null when nothing fits or every claim lost.
93
+ function claimTaskForMember(taskDb, db, { workspaceRoot, member, job, requireCheck = false }) {
94
+ for (const task of fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck })) {
95
+ let result;
96
+ try {
97
+ result = taskDb.claimTask(db, { id: task.id, claimedBy: member });
98
+ } catch (error) {
99
+ // A claim that half-wrote must not strand the task.
100
+ try { taskDb.releaseTask(db, { id: task.id, actor: member }); } catch {}
101
+ throw error;
102
+ }
103
+ if (result.claimed) return { task: result.row, objective: taskObjective(result.row) };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ module.exports = {
109
+ taskCheck,
110
+ claimTaskForMember,
111
+ fittingTasks,
112
+ jobForTask,
113
+ pickTaskForMember,
114
+ skipReason,
115
+ taskObjective,
116
+ };