atris 3.69.0 → 3.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +40 -1
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +63 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
package/lib/member-up.js CHANGED
@@ -4,27 +4,150 @@
4
4
  // working. Each check it looks at every runnable member, and when one has no
5
5
  // live run and a task with a runnable check fits it, starts
6
6
  // `atris member go <name> --needs-check` so the member claims and runs that
7
- // task. A member with no such task stays idle; it never falls back to its
8
- // own mission or goal text. The clock, the spawner, the member list and the
9
- // task picker are passed in, so tests drive it with fake time.
7
+ // task. A member with no such task writes its own next job from its standing
8
+ // mission when that mission names a check (lib/member-self-next-job.js), and
9
+ // starts on it; otherwise it stays idle. The clock, the spawner, the member
10
+ // list and the task picker are passed in, so tests drive it with fake time.
10
11
 
11
12
  const { taskCheck } = require('./member-task-pick');
12
13
  const { followBoard } = require('./member-board');
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { spawnSync } = require('child_process');
13
17
 
14
18
  const CHECK_MS = 60 * 1000;
15
19
  const IDLE_CHECK_MS = 5 * 60 * 1000;
20
+ const IDLE_MAX_CHECK_MS = 30 * 60 * 1000;
16
21
  const SUMMARY_MS = 10 * 60 * 1000;
22
+ // A run with no new step for this long is called out as stuck, once per stall.
23
+ const STALL_MS = 20 * 60 * 1000;
24
+ // How often member changes GitHub dropped from its merge line are put back.
25
+ // Each look can call GitHub, so it runs less often than the member check.
26
+ const REQUEUE_MS = 5 * 60 * 1000;
27
+ // Put-back outcomes worth a line: one went back in, or one was left for you.
28
+ const REQUEUE_SAID = new Set(['requeued', 'clash', 'gave-up', 'changed', 'moved', 'stalled', 'refreshed', 'refresh-failed']);
29
+ const MAINTENANCE_LOCK_MS = 2 * 60 * 1000;
30
+ const MERGE_QUIET_MS = 2 * 60 * 60 * 1000;
17
31
  const IDLE_AFTER_EMPTY = 3;
18
32
  const DEFAULT_MAX = 6;
19
33
  // Exit code `member go --needs-check` uses when it found no task with a check
20
34
  // to claim (another run took it first). Not a failure, just idle.
21
35
  const IDLE_EXIT = 75;
36
+ // A member name that gets each job by itself, for the add-one hint.
37
+ const JOB_MEMBER = { build: 'builder', review: 'reviewer', search: 'researcher' };
38
+
39
+ function maintenanceGit(cwd, args) {
40
+ const out = spawnSync('git', args, {
41
+ cwd, encoding: 'utf8', timeout: 30000,
42
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_TERMINAL_PROMPT: '0' },
43
+ });
44
+ return { ok: out.status === 0, text: String(out.stdout || '').trim() };
45
+ }
46
+
47
+ // Never move a developer's branch, a linked work folder, or any edited copy.
48
+ // Recheck after the fetch, which can take long enough for edits to arrive.
49
+ function refreshMemberCode(root) {
50
+ const safe = () => {
51
+ try { if (!fs.lstatSync(path.join(root, '.git')).isDirectory()) return false; } catch { return false; }
52
+ const detached = maintenanceGit(root, ['symbolic-ref', '-q', 'HEAD']);
53
+ const clean = maintenanceGit(root, ['status', '--porcelain', '--untracked-files=all']);
54
+ return !detached.ok && clean.ok && !clean.text;
55
+ };
56
+ if (!safe()) return null;
57
+ const before = maintenanceGit(root, ['rev-parse', '--verify', 'HEAD']);
58
+ const fetched = maintenanceGit(root, ['fetch', '-q', 'origin', '+refs/heads/master:refs/remotes/origin/master']);
59
+ if (!fetched.ok) return { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
60
+ const tip = maintenanceGit(root, ['rev-parse', '--verify', 'refs/remotes/origin/master^{commit}']);
61
+ const current = maintenanceGit(root, ['rev-parse', '--verify', 'HEAD']);
62
+ if (!before.ok || !tip.ok || !current.ok || current.text !== before.text || !safe()) return null;
63
+ if (tip.text === before.text) return null;
64
+ // A detached local commit is still someone's work. Only move forward.
65
+ if (!maintenanceGit(root, ['merge-base', '--is-ancestor', before.text, tip.text]).ok) return null;
66
+ const moved = maintenanceGit(root, ['checkout', '--detach', tip.text]);
67
+ return moved.ok
68
+ ? { state: 'refreshed', line: 'the members\' code copy is current' }
69
+ : { state: 'refresh-failed', line: 'could not refresh the members\' code copy; will try again' };
70
+ }
71
+
72
+ // Shared by all windows of a project, including linked work folders. The
73
+ // completed-pass timestamp survives release, so a second window waits too.
74
+ function memberMaintenance({ cwd, codeRoot, now = Date.now(), requeueDropped, waitingTasks = () => [] }) {
75
+ const common = maintenanceGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
76
+ if (!common.ok) return { results: [] };
77
+ const dir = path.join(path.dirname(common.text), '.atris', 'state');
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ const lock = path.join(dir, 'member-up-maintenance.lock');
80
+ const file = path.join(dir, 'member-up-maintenance.json');
81
+ let fd;
82
+ try {
83
+ fd = fs.openSync(lock, 'wx');
84
+ } catch (error) {
85
+ if (error.code !== 'EEXIST') throw error;
86
+ // A live owner always holds, even past the lease: GitHub may be slow.
87
+ // Unknown/malformed owners get a short grace period before recovery.
88
+ try {
89
+ const stat = fs.statSync(lock);
90
+ let owner;
91
+ try { owner = JSON.parse(fs.readFileSync(lock, 'utf8')); } catch {}
92
+ if (Number.isInteger(owner?.pid) && owner.pid > 0) {
93
+ try { process.kill(owner.pid, 0); return { results: [] }; }
94
+ catch (e) { if (e.code !== 'ESRCH') return { results: [] }; }
95
+ } else if (now - stat.mtimeMs < MAINTENANCE_LOCK_MS) return { results: [] };
96
+ if (fs.statSync(lock).ino !== stat.ino) return { results: [] };
97
+ fs.unlinkSync(lock);
98
+ fd = fs.openSync(lock, 'wx');
99
+ } catch { return { results: [] }; }
100
+ }
101
+ try {
102
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }));
103
+ let state = {};
104
+ try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
105
+ if (Number.isFinite(state.checked_at) && now - state.checked_at < REQUEUE_MS) return { results: [] };
106
+ const save = () => {
107
+ const tmp = `${file}.${process.pid}.tmp`;
108
+ fs.writeFileSync(tmp, JSON.stringify(state));
109
+ fs.renameSync(tmp, file);
110
+ };
111
+ state.checked_at = now;
112
+ save();
113
+ const out = requeueDropped();
114
+ const results = [...(out?.results || [])];
115
+ const merged = results.some((r) => r.state === 'merged');
116
+ const waiting = results.some((r) => !['merged', 'closed'].includes(r.state)) || waitingTasks().length > 0;
117
+ if (merged || !waiting) {
118
+ state.waiting_since = null;
119
+ state.quiet_said = false;
120
+ }
121
+ if (waiting && state.waiting_since == null) state.waiting_since = now;
122
+ if (waiting && !state.quiet_said && now - state.waiting_since >= MERGE_QUIET_MS) {
123
+ results.push({ state: 'stalled', line: 'jobs are waiting, but nothing has merged for two hours' });
124
+ state.quiet_said = true;
125
+ }
126
+ const refresh = codeRoot ? refreshMemberCode(codeRoot) : null;
127
+ if (refresh && (refresh.state !== 'refresh-failed' || !state.refresh_failed)) results.push(refresh);
128
+ state.refresh_failed = refresh?.state === 'refresh-failed';
129
+ save();
130
+ return { ...out, results };
131
+ } finally {
132
+ fs.closeSync(fd);
133
+ fs.unlinkSync(lock);
134
+ }
135
+ }
22
136
 
23
137
  function dayOf(ms) {
24
138
  const d = new Date(ms);
25
139
  return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
26
140
  }
27
141
 
142
+ // How long an idle member waits before its next check. The first few empty
143
+ // checks wait one round; after that the wait doubles from 5 minutes each
144
+ // empty check, up to 30 minutes. Finding work resets it.
145
+ function idleWaitMs(empty) {
146
+ if (!(empty >= IDLE_AFTER_EMPTY)) return 0;
147
+ const doublings = Math.min(empty - IDLE_AFTER_EMPTY, 10);
148
+ return Math.min(IDLE_CHECK_MS * 2 ** doublings, IDLE_MAX_CHECK_MS);
149
+ }
150
+
28
151
  function oneLine(text) {
29
152
  return String(text || '').replace(/\s+/g, ' ').trim();
30
153
  }
@@ -38,9 +161,17 @@ function hasRunnableCheck(task) {
38
161
  // deps:
39
162
  // listMembers() -> [{ name, parked, human, sleeping, live }]
40
163
  // pickTask(name, takenIds) -> { id, title } | null (looks only, never claims)
41
- // spawn(name, onExit) -> any (onExit(code) when it ends)
164
+ // spawn(name, onExit, onClaim) -> any (claim confirms { id, title })
42
165
  // now() -> ms
43
166
  // print(line)
167
+ // jobOf(name) -> 'build' | 'review' | 'search' (optional)
168
+ // writeNextJob(name, { dryRun }) -> { task } | { skip } (optional; writes
169
+ // the member's next job from its mission when its list
170
+ // is empty; a dry run only says what it would write)
171
+ // waitingTasks() -> [{ id, title, job }] (optional; open tasks
172
+ // with a runnable check, any job)
173
+ // requeueDropped() -> { results: [{ state, line }] } (optional; puts member
174
+ // changes GitHub dropped from its merge line back in)
44
175
  function createMemberUp(deps, options = {}) {
45
176
  const max = Math.max(1, Number(options.max) || DEFAULT_MAX);
46
177
  const dryRun = options.dryRun === true;
@@ -51,11 +182,14 @@ function createMemberUp(deps, options = {}) {
51
182
  ? new Set(options.only.map((name) => String(name).trim()).filter(Boolean))
52
183
  : null;
53
184
  const skippedUnknown = new Set();
185
+ const hintedJobs = new Set();
54
186
  const state = new Map();
55
187
  const running = new Map();
56
188
  let doneToday = 0;
57
189
  let doneDay = dayOf(deps.now());
58
190
  let lastSummaryAt = deps.now();
191
+ let lastRequeueAt = -Infinity;
192
+ let requeueFailSaid = false;
59
193
  // Members working right now: the runs this supervisor started plus any
60
194
  // live run started some other way. Both count toward --max.
61
195
  let liveElsewhere = new Set();
@@ -66,6 +200,20 @@ function createMemberUp(deps, options = {}) {
66
200
  return state.get(name);
67
201
  };
68
202
 
203
+ function claimed(name, run, task) {
204
+ if (running.get(name) !== run || run.announced || !task?.id || !task?.title) return;
205
+ run.announced = true;
206
+ run.taskId = task.id;
207
+ const s = seat(name);
208
+ const title = oneLine(task.title);
209
+ run.title = title;
210
+ if (s.idle) print(`${name} woke up for: ${title}`);
211
+ s.idle = false;
212
+ s.empty = 0;
213
+ s.nextCheckAt = 0;
214
+ print(`${name} started on: ${title}`);
215
+ }
216
+
69
217
  function finish(name, code, result = {}) {
70
218
  if (!running.has(name)) return;
71
219
  const run = running.get(name);
@@ -95,6 +243,29 @@ function createMemberUp(deps, options = {}) {
95
243
 
96
244
  const idleLine = (name) => `${name} is idle, no task with a check`;
97
245
 
246
+ // Jobs waiting for a kind of work no awake member does, said once per kind:
247
+ // name a member who could be woken for it, or say to add one.
248
+ function otherKindHints(team, awake, taken) {
249
+ if (!deps.jobOf || !deps.waitingTasks) return;
250
+ const awakeJobs = new Set(awake.map((m) => deps.jobOf(m.name)));
251
+ const waiting = new Map();
252
+ for (const task of deps.waitingTasks() || []) {
253
+ if (!task || !task.job || (task.id && taken.has(task.id))) continue;
254
+ if (awakeJobs.has(task.job) || hintedJobs.has(task.job)) continue;
255
+ waiting.set(task.job, (waiting.get(task.job) || 0) + 1);
256
+ }
257
+ const awakeNames = new Set(awake.map((m) => m.name));
258
+ for (const [job, count] of waiting) {
259
+ hintedJobs.add(job);
260
+ const jobs = count === 1 ? `1 ${job} job is waiting` : `${count} ${job} jobs are waiting`;
261
+ const sleeper = team.find((m) => m && m.name && !m.human && !m.parked
262
+ && !awakeNames.has(m.name) && deps.jobOf(m.name) === job);
263
+ print(sleeper
264
+ ? `${jobs}, but nobody who does ${job} is awake. wake one: atris member wake ${sleeper.name} --keep`
265
+ : `${jobs}, but no member on the team does ${job}. add one: atris member create ${JOB_MEMBER[job] || job}`);
266
+ }
267
+ }
268
+
98
269
  function progress(event) {
99
270
  const run = running.get(event.member);
100
271
  if (!run || Date.parse(event.at) < run.startedAt) return;
@@ -104,6 +275,9 @@ function createMemberUp(deps, options = {}) {
104
275
  }
105
276
  if (event.kind !== 'step') return;
106
277
  if (event.text.startsWith('editing ')) run.changed = event.text;
278
+ run.lastStepAt = Math.max(run.lastStepAt, Date.parse(event.at) || 0);
279
+ run.lastStep = oneLine(event.text);
280
+ run.stallSaid = false;
107
281
  const time = new Date(event.at);
108
282
  const hhmm = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
109
283
  print(` ${event.member} ${hhmm} ${oneLine(event.text)}`);
@@ -114,9 +288,60 @@ function createMemberUp(deps, options = {}) {
114
288
  return `working ${working()}, idle ${idle}, done today ${doneToday}`;
115
289
  }
116
290
 
291
+ // Say once when a run has shown no new step for STALL_MS, so a hung member
292
+ // does not hold a seat in silence. A new step lets a later stall be said again.
293
+ function callOutStalls(now) {
294
+ for (const [name, run] of running) {
295
+ const quietMs = now - run.lastStepAt;
296
+ if (run.stallSaid || quietMs < STALL_MS) continue;
297
+ run.stallSaid = true;
298
+ print(`${name} looks stuck: no progress for ${Math.floor(quietMs / 60000)} minutes on ${run.title}. `
299
+ + `last step: ${run.lastStep || 'none yet'}`);
300
+ }
301
+ }
302
+
303
+ // Every REQUEUE_MS, put back member changes GitHub dropped from its merge
304
+ // line, so nobody has to run atris land --requeue. A dry run never pushes.
305
+ // A failure is said once until a later look works.
306
+ function putBackDropped(now) {
307
+ if (dryRun || !deps.requeueDropped || now - lastRequeueAt < REQUEUE_MS) return;
308
+ lastRequeueAt = now;
309
+ let out;
310
+ try {
311
+ out = deps.requeueDropped();
312
+ requeueFailSaid = false;
313
+ } catch (error) {
314
+ if (!requeueFailSaid) print(`could not check github's merge line: ${oneLine(error && error.message || error)}`);
315
+ requeueFailSaid = true;
316
+ return;
317
+ }
318
+ for (const result of (out && out.results) || []) {
319
+ if (result && REQUEUE_SAID.has(result.state)) print(oneLine(result.line));
320
+ }
321
+ }
322
+
323
+ // An empty list: the member writes its next job from its mission. The new
324
+ // task is the one it starts on; a skip or an error leaves it idle.
325
+ function ownNextJob(name, s) {
326
+ if (!deps.writeNextJob) return null;
327
+ let out;
328
+ try {
329
+ out = deps.writeNextJob(name, { dryRun });
330
+ } catch (error) {
331
+ if (!s.idle) print(`${name} could not write its own next job: ${oneLine(error && error.message || error)}`);
332
+ return null;
333
+ }
334
+ const task = out && out.task;
335
+ if (!task || !hasRunnableCheck(task)) return null;
336
+ print(`${name} had no job, so it ${dryRun ? 'would write' : 'wrote'} its own from its mission: ${oneLine(task.title)}`);
337
+ return task;
338
+ }
339
+
117
340
  // One pass over the team. Returns the members it started (or would start).
118
341
  function check() {
119
342
  const now = deps.now();
343
+ callOutStalls(now);
344
+ putBackDropped(now);
120
345
  const started = [];
121
346
  const taken = new Set(Array.from(running.values()).map((run) => run.taskId).filter(Boolean));
122
347
  const team = deps.listMembers();
@@ -138,12 +363,14 @@ function createMemberUp(deps, options = {}) {
138
363
  const s = seat(name);
139
364
  if (now < s.nextCheckAt) continue;
140
365
  const picked = deps.pickTask(name, taken);
141
- const task = picked && hasRunnableCheck(picked) ? picked : null;
366
+ let task = picked && hasRunnableCheck(picked) ? picked : null;
367
+ if (!task) task = ownNextJob(name, s);
142
368
  if (!task) {
143
369
  s.empty += 1;
144
370
  if (!s.idle && !dryRun) print(idleLine(name));
145
371
  s.idle = true;
146
- if (s.empty >= IDLE_AFTER_EMPTY) s.nextCheckAt = now + IDLE_CHECK_MS;
372
+ const wait = idleWaitMs(s.empty);
373
+ if (wait) s.nextCheckAt = now + wait;
147
374
  continue;
148
375
  }
149
376
  const title = oneLine(task.title);
@@ -153,24 +380,23 @@ function createMemberUp(deps, options = {}) {
153
380
  started.push({ name, task });
154
381
  continue;
155
382
  }
156
- if (s.idle) print(`${name} woke up for: ${title}`);
157
- s.idle = false;
158
- s.empty = 0;
159
- s.nextCheckAt = 0;
160
- running.set(name, { taskId: task.id || null, startedAt: now });
383
+ const run = { taskId: task.id || null, title, startedAt: now, lastStepAt: now, announced: false };
384
+ running.set(name, run);
161
385
  try {
162
386
  deps.spawn(name, (code, result) => {
163
387
  deps.flushProgress?.();
164
- finish(name, code, result);
165
- });
388
+ if (running.get(name) === run) finish(name, code, result);
389
+ }, (claimedTask) => claimed(name, run, claimedTask));
166
390
  } catch (error) {
167
391
  running.delete(name);
168
392
  print(`${name} failed to start: ${oneLine(error && error.message || error)}`);
169
393
  continue;
170
394
  }
171
- print(`${name} started on: ${title}`);
172
395
  started.push({ name, task });
173
396
  }
397
+ try {
398
+ otherKindHints(team, members.filter((m) => m && m.name && !m.parked && !m.human && !m.sleeping), taken);
399
+ } catch {}
174
400
  if (!dryRun && now - lastSummaryAt >= SUMMARY_MS) {
175
401
  lastSummaryAt = now;
176
402
  print(summary());
@@ -209,7 +435,7 @@ function memberUpResult(stdout, stderr = '', signal = '') {
209
435
  : oneLine(stderr) || 'run ended without a checked task result' };
210
436
  }
211
437
 
212
- // Reads `member up` flags: --max N, --only a,b and --dry-run.
438
+ // Reads `member up` flags: --max N, --only a,b and --dry-run (or --plan).
213
439
  // Returns { max, only, dryRun } or { error }.
214
440
  function parseMemberUpArgs(args = []) {
215
441
  const list = args.map((arg) => String(arg == null ? '' : arg));
@@ -219,7 +445,9 @@ function parseMemberUpArgs(args = []) {
219
445
  const inline = list.find((arg) => arg.startsWith(`${flag}=`));
220
446
  return inline == null ? null : inline.slice(flag.length + 1);
221
447
  };
222
- const out = { max: undefined, only: null, dryRun: list.includes('--dry-run') };
448
+ // --plan only plans, same as on a single wake: `member wake a --keep --plan`
449
+ // must never start a paid run.
450
+ const out = { max: undefined, only: null, dryRun: list.includes('--dry-run') || list.includes('--plan') };
223
451
  const maxText = valueOf('--max');
224
452
  if (maxText != null) {
225
453
  const max = Number(maxText);
@@ -278,9 +506,14 @@ module.exports = {
278
506
  DEFAULT_MAX,
279
507
  IDLE_CHECK_MS,
280
508
  IDLE_EXIT,
509
+ IDLE_MAX_CHECK_MS,
510
+ REQUEUE_MS,
511
+ STALL_MS,
281
512
  SUMMARY_MS,
282
513
  createMemberUp,
514
+ idleWaitMs,
283
515
  memberUpResult,
516
+ memberMaintenance,
284
517
  parseMemberUpArgs,
285
518
  runMemberUp,
286
519
  };
@@ -19,6 +19,7 @@ const {
19
19
  const { spawnSync } = require('child_process');
20
20
  const { buildReadOnlyEngineInvocation, buildAskSpawnEnv, runAskProcess } = require('./engine-ask');
21
21
  const { appendRosterRun, parseRunUsage } = require('./roster-runs');
22
+ const { startEngineRun } = require('./engine-resource');
22
23
 
23
24
  // A brief is at most 300 lines and 20 KB: enough for a dozen file:line
24
25
  // pointers with short excerpts, small enough that a heavy model reads it in
@@ -154,7 +155,7 @@ function defaultPrepAskSync({ engine, model, effort, prompt, cwd, timeoutMs }) {
154
155
  else if (spawnError) reason = 'spawn_error';
155
156
  else if (run.status !== 0) reason = `exit_${run.status == null ? 'unknown' : run.status}`;
156
157
  else if (!hasOutput) reason = 'no_output';
157
- return { ok: reason === 'ok', reason, timed_out: timedOut, stdout, stderr: spawnError ? `${stderr}${spawnError.message}` : stderr };
158
+ return { ok: reason === 'ok', reason, timed_out: timedOut, signal: run.signal || null, stdout, stderr: spawnError ? `${stderr}${spawnError.message}` : stderr };
158
159
  }
159
160
 
160
161
  function skipped(job, reason, extra = {}) {
@@ -211,7 +212,9 @@ function finishPrepPass(plan, answer, { startedMs, endedMs, task, root = process
211
212
  reason = exit ? `${engine} exited ${exit[1]}`
212
213
  : answer.reason === 'no_output' ? 'returned nothing'
213
214
  : answer.reason === 'spawn_error' ? `${engine} could not start`
214
- : `${engine} failed`;
215
+ : answer.signal ? `${engine} stopped by ${answer.signal}`
216
+ : `${engine} failed`;
217
+ if (answer.cause) reason = `${reason}; ${answer.cause}`;
215
218
  } else if (!brief.text) {
216
219
  outcome = 'failed';
217
220
  reason = 'returned nothing';
@@ -239,6 +242,22 @@ function askFailure(error) {
239
242
  return { ok: false, reason: 'runner_error', stderr: String(error && error.message || error), stdout: '' };
240
243
  }
241
244
 
245
+ // Machine memory and load at both ends of the prep launch. The ask answers
246
+ // instead of throwing, so a failure is passed in the thrown shape, and a
247
+ // SIGKILL hands its cause back on the answer.
248
+ function startPrepReadings(plan, root) {
249
+ const endResource = startEngineRun(root || process.cwd(), { engine: plan.engine, task: `prep for ${plan.forLabel}` });
250
+ return (answer) => {
251
+ const end = endResource(answer.ok ? null : {
252
+ code: answer.timed_out ? 'ETIMEDOUT' : '',
253
+ signal: answer.signal,
254
+ stderr: answer.stderr,
255
+ message: answer.reason,
256
+ });
257
+ return end && end.cause && !answer.timed_out ? { ...answer, cause: end.cause } : answer;
258
+ };
259
+ }
260
+
242
261
  // One prep pass for one task. Never throws: every way it can go wrong comes
243
262
  // back as { ok: false, reason } so the heavy worker still runs.
244
263
  async function runPrepPass({
@@ -250,12 +269,14 @@ async function runPrepPass({
250
269
  const plan = planPrepPass(options);
251
270
  if (plan.skip) return plan.skip;
252
271
  const startedMs = clock();
272
+ const endReadings = startPrepReadings(plan, options.root);
253
273
  let answer;
254
274
  try {
255
275
  answer = await ask(plan.request);
256
276
  } catch (error) {
257
277
  answer = askFailure(error);
258
278
  }
279
+ answer = endReadings(answer || { ok: false, reason: 'no_answer', stdout: '' });
259
280
  return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
260
281
  }
261
282
 
@@ -269,12 +290,14 @@ function runPrepPassSync({
269
290
  const plan = planPrepPass(options);
270
291
  if (plan.skip) return plan.skip;
271
292
  const startedMs = clock();
293
+ const endReadings = startPrepReadings(plan, options.root);
272
294
  let answer;
273
295
  try {
274
296
  answer = ask(plan.request);
275
297
  } catch (error) {
276
298
  answer = askFailure(error);
277
299
  }
300
+ answer = endReadings(answer || { ok: false, reason: 'no_answer', stdout: '' });
278
301
  return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
279
302
  }
280
303
 
@@ -43,6 +43,11 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
43
43
  model: 'atris:fast',
44
44
  commandTemplate: '{bin} --fast {prompt}',
45
45
  }),
46
+ mercury: Object.freeze({
47
+ bin: 'ax',
48
+ model: 'inception:mercury-3-preview-1002',
49
+ commandTemplate: '{bin} --mercury {prompt}',
50
+ }),
46
51
  // Guest engines: any installed headless coding CLI is a swappable worker.
47
52
  // No commandTemplate on claude, the default spawn shape is already
48
53
  // claude-compatible (bin -p prompt --model X --allowedTools ...).
package/lib/task-db.js CHANGED
@@ -31,7 +31,7 @@ const dailyLog = require('./daily-log');
31
31
  const { isGenericScratchRoot } = require('./scratch-root');
32
32
  const { isDecisionTask } = require('./task-decision');
33
33
  const { parseVerifyCommand } = require('./auto-accept-certified');
34
- const { taskExplanation } = require('./task-explanation');
34
+ const { explicitDoneLooksLike, taskExplanation } = require('./task-explanation');
35
35
  const { treeHashFor } = require('./tree-hash');
36
36
  const { sleepSync } = require('./sleep-sync');
37
37
  const { atrisHome } = require('./state-home');
@@ -454,6 +454,21 @@ function addTask(db, { title, tag, workspaceRoot: ws, sourceKey: sk, metadata, s
454
454
  return { id, inserted: true };
455
455
  }
456
456
 
457
+ // Same job = same title ignoring case and spacing. Only jobs not yet handed in
458
+ // (open or claimed) count, so a job in review, done, or failed can be filed again.
459
+ function normalizeTaskTitle(title) {
460
+ return String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
461
+ }
462
+
463
+ function findUnfinishedTaskByTitle(db, { workspaceRoot: ws, title }) {
464
+ const wanted = normalizeTaskTitle(title);
465
+ if (!wanted) return null;
466
+ const rows = db.prepare(
467
+ "SELECT id, title FROM tasks WHERE workspace_root = ? AND status IN ('open', 'claimed') ORDER BY created_at ASC"
468
+ ).all(ws);
469
+ return rows.find((row) => normalizeTaskTitle(row.title) === wanted) || null;
470
+ }
471
+
457
472
  // A main checkout's view also covers its linked git worktrees, whose tasks keep
458
473
  // the worktree as workspace_root so checks run where the work lives. Only a
459
474
  // main checkout with linked worktrees spawns git; any error means exact match.
@@ -544,6 +559,22 @@ function getTask(db, id) {
544
559
  return { ...row, metadata, ...(unreadable ? { metadata_unreadable: true } : {}) };
545
560
  }
546
561
 
562
+ // The commit a workspace sits on. Claim and ready stamp it so a landing can
563
+ // later be proven from the task's own change (lib/landing-proof.js) instead
564
+ // of whatever the checkout's HEAD happens to be at accept time.
565
+ function workspaceHead(workspaceRoot) {
566
+ if (!workspaceRoot) return null;
567
+ try {
568
+ const result = require('child_process').spawnSync('git', ['rev-parse', 'HEAD'], {
569
+ cwd: workspaceRoot, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'],
570
+ });
571
+ const sha = String(result.stdout || '').trim();
572
+ return result.status === 0 && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
573
+ } catch {
574
+ return null;
575
+ }
576
+ }
577
+
547
578
  // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
548
579
  // Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
549
580
  // writes; busy_timeout absorbs contention. Caller must check `.claimed`.
@@ -562,13 +593,21 @@ function claimTask(db, { id, claimedBy }) {
562
593
  `);
563
594
  const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
564
595
  if (result.changes === 1) {
565
- const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
596
+ let row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
597
+ // First claim wins: a re-claim after a revision keeps the original start.
598
+ const startCommit = workspaceHead(row.workspace_root);
599
+ const claimedMetadata = row.metadata ? safeJSON(row.metadata) || {} : {};
600
+ if (startCommit && !claimedMetadata.start_commit) {
601
+ claimedMetadata.start_commit = startCommit;
602
+ withBusyRetry(() => db.prepare('UPDATE tasks SET metadata = ? WHERE id = ?').run(JSON.stringify(claimedMetadata), id));
603
+ row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
604
+ }
566
605
  appendTaskEvent(db, {
567
606
  taskId: id,
568
607
  workspaceRoot: row.workspace_root,
569
608
  actor: claimedBy,
570
609
  eventType: 'claimed',
571
- payload: { claimed_by: claimedBy },
610
+ payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
572
611
  });
573
612
  const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
574
613
  appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
@@ -580,7 +619,7 @@ function claimTask(db, { id, claimedBy }) {
580
619
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
581
620
  }
582
621
 
583
- function releaseTask(db, { id, actor }) {
622
+ function releaseTask(db, { id, actor, expectedUpdatedAt }) {
584
623
  if (!id) throw new Error('id required');
585
624
  if (!actor) throw new Error('actor required');
586
625
  const row = getTask(db, id);
@@ -601,7 +640,8 @@ function releaseTask(db, { id, actor }) {
601
640
  WHERE id = ?
602
641
  AND status = 'claimed'
603
642
  AND claimed_by = ?
604
- `).run(now, id, actor));
643
+ AND (? IS NULL OR updated_at = ?)
644
+ `).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
605
645
  if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
606
646
  const updated = getTask(db, id);
607
647
  const event = appendTaskEvent(db, {
@@ -617,12 +657,14 @@ function releaseTask(db, { id, actor }) {
617
657
  return { released: true, row: updated, event };
618
658
  }
619
659
 
620
- function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
660
+ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
621
661
  if (!id) throw new Error('id required');
622
662
  const row = getTask(db, id);
623
663
  if (!row) return { reopened: false, reason: 'not_found' };
624
- if (OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
625
- if (!['done', 'failed', 'archived'].includes(row.status)) {
664
+ // A job marked done and waiting to merge can be sent back only on request.
665
+ const reviewReopen = fromReview && row.status === 'review';
666
+ if (!reviewReopen && OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
667
+ if (!reviewReopen && !['done', 'failed', 'archived'].includes(row.status)) {
626
668
  return { reopened: false, reason: `not_reopenable_${row.status}` };
627
669
  }
628
670
 
@@ -897,7 +939,7 @@ function recordReviewPassActor(metadata, claimedBy, rawActor) {
897
939
  return { builder: builder || null, independent };
898
940
  }
899
941
 
900
- function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace, result: resultSentence, reason }) {
942
+ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resultTrace, result: resultSentence, reason, workRoot }) {
901
943
  if (!id) throw new Error('id required');
902
944
  const text = String(proof || '').trim();
903
945
  if (!text) throw new Error('proof required');
@@ -925,6 +967,15 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
925
967
  const cleaned = cleanLandingText(landingInput[key]);
926
968
  if (cleaned) metadata[`landing_${key}`] = cleaned;
927
969
  }
970
+ // Only the builder's own ready names the work's commit. A reviewer or the
971
+ // autoland verifier re-runs the check from its own checkout; stamping that
972
+ // checkout's HEAD would name a commit the builder never made. No actor, or
973
+ // no builder on record, means nobody can vouch for HEAD, so nothing is stamped.
974
+ // A builder working in its own copy passes that copy as workRoot.
975
+ if (actor && row.claimed_by && actor === row.claimed_by && !reviewIntegrity.isReservedActor(actor)) {
976
+ const head = workspaceHead(workRoot || row.workspace_root);
977
+ if (head) metadata.work_commit = head;
978
+ }
928
979
  const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
929
980
  if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
930
981
  metadata.agent_certified = true;
@@ -1287,7 +1338,7 @@ function stageTask(db, {
1287
1338
  let stageOwner = requestedOwner || actorText || cleanStageText(row.claimed_by) || null;
1288
1339
  let goalText = requestedGoal || cleanStageText(metadata.task_goal || metadata.goal_objective || metadata.objective || metadata.stage_goal);
1289
1340
  let proofText = requestedProof || cleanStageText(metadata.verify || metadata.proof_needed);
1290
- let exitText = requestedExit || cleanStageText(metadata.exit_condition);
1341
+ let exitText = requestedExit || cleanStageText(metadata.exit_condition) || cleanStageText(explicitDoneLooksLike(metadata));
1291
1342
 
1292
1343
  function recordedDoPlan(currentRow, { claimedOwnerWins = false } = {}) {
1293
1344
  const currentMetadata = currentRow && currentRow.metadata && typeof currentRow.metadata === 'object'
@@ -1299,7 +1350,7 @@ function stageTask(db, {
1299
1350
  const caller = actorText || requestedOwner || null;
1300
1351
  const recordedGoal = cleanStageText(currentMetadata.goal_objective || currentMetadata.objective || currentMetadata.stage_goal || currentMetadata.task_goal);
1301
1352
  const recordedProof = cleanStageText(currentMetadata.verify || currentMetadata.proof_needed);
1302
- const recordedExit = cleanStageText(currentMetadata.exit_condition);
1353
+ const recordedExit = cleanStageText(currentMetadata.exit_condition) || cleanStageText(explicitDoneLooksLike(currentMetadata));
1303
1354
  const planRecorded = cleanStageText(currentMetadata.planned_at)
1304
1355
  || cleanStageText(currentMetadata.stage) === 'plan'
1305
1356
  || cleanStageText(currentMetadata.stage_plan_recorded_at);
@@ -2304,6 +2355,7 @@ module.exports = {
2304
2355
  sourceKey,
2305
2356
  normalizeTitle,
2306
2357
  addTask,
2358
+ findUnfinishedTaskByTitle,
2307
2359
  getTask,
2308
2360
  listTasks,
2309
2361
  listTasksCached,
@@ -81,6 +81,12 @@ function explicitExplanationFields(metadata) {
81
81
  return out;
82
82
  }
83
83
 
84
+ // The author's own "done looks like" line, or empty when it was only derived.
85
+ // A job that states this has a finish line, so plan gates read it as one.
86
+ function explicitDoneLooksLike(metadata) {
87
+ return explicitExplanationFields(metadata).done_looks_like || '';
88
+ }
89
+
84
90
  function explanationGoalText(task) {
85
91
  const metadata = task && task.metadata || {};
86
92
  return cleanText(metadata.task_goal || metadata.goal_objective || (task && task.objective) || metadata.objective);
@@ -219,6 +225,7 @@ module.exports = {
219
225
  EXPLANATION_FIELDS,
220
226
  EXPLANATION_LABELS,
221
227
  NO_REASON_RECORDED,
228
+ explicitDoneLooksLike,
222
229
  plainText,
223
230
  taskExplanation,
224
231
  explanationFieldsFromInput,