atris 3.70.0 → 3.71.1

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 +18 -0
  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 +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -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/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -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 +47 -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');
@@ -559,6 +559,22 @@ function getTask(db, id) {
559
559
  return { ...row, metadata, ...(unreadable ? { metadata_unreadable: true } : {}) };
560
560
  }
561
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
+
562
578
  // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
563
579
  // Race-safe via single UPDATE with WHERE status='open' guard. SQLite serializes
564
580
  // writes; busy_timeout absorbs contention. Caller must check `.claimed`.
@@ -577,13 +593,21 @@ function claimTask(db, { id, claimedBy }) {
577
593
  `);
578
594
  const result = withBusyRetry(() => stmt.run(claimedBy, now, now, id));
579
595
  if (result.changes === 1) {
580
- 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
+ }
581
605
  appendTaskEvent(db, {
582
606
  taskId: id,
583
607
  workspaceRoot: row.workspace_root,
584
608
  actor: claimedBy,
585
609
  eventType: 'claimed',
586
- payload: { claimed_by: claimedBy },
610
+ payload: { claimed_by: claimedBy, ...(startCommit ? { start_commit: startCommit } : {}) },
587
611
  });
588
612
  const enriched = { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
589
613
  appendTaskWorkLog(db, enriched, { actor: claimedBy, title: 'Task claimed' });
@@ -595,7 +619,7 @@ function claimTask(db, { id, claimedBy }) {
595
619
  return { claimed: false, reason: 'already_' + row.status, claimed_by: row.claimed_by };
596
620
  }
597
621
 
598
- function releaseTask(db, { id, actor }) {
622
+ function releaseTask(db, { id, actor, expectedUpdatedAt }) {
599
623
  if (!id) throw new Error('id required');
600
624
  if (!actor) throw new Error('actor required');
601
625
  const row = getTask(db, id);
@@ -616,7 +640,8 @@ function releaseTask(db, { id, actor }) {
616
640
  WHERE id = ?
617
641
  AND status = 'claimed'
618
642
  AND claimed_by = ?
619
- `).run(now, id, actor));
643
+ AND (? IS NULL OR updated_at = ?)
644
+ `).run(now, id, actor, expectedUpdatedAt ?? null, expectedUpdatedAt ?? null));
620
645
  if (result.changes !== 1) return { released: false, reason: 'stale_task_state', claimed_by: row.claimed_by || null };
621
646
  const updated = getTask(db, id);
622
647
  const event = appendTaskEvent(db, {
@@ -632,12 +657,14 @@ function releaseTask(db, { id, actor }) {
632
657
  return { released: true, row: updated, event };
633
658
  }
634
659
 
635
- function reopenTask(db, { id, actor, reason, metadata: metadataPatch } = {}) {
660
+ function reopenTask(db, { id, actor, reason, metadata: metadataPatch, fromReview = false } = {}) {
636
661
  if (!id) throw new Error('id required');
637
662
  const row = getTask(db, id);
638
663
  if (!row) return { reopened: false, reason: 'not_found' };
639
- if (OPEN_TASK_STATUSES.has(row.status)) return { reopened: false, reason: `already_${row.status}`, row };
640
- 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)) {
641
668
  return { reopened: false, reason: `not_reopenable_${row.status}` };
642
669
  }
643
670
 
@@ -912,7 +939,7 @@ function recordReviewPassActor(metadata, claimedBy, rawActor) {
912
939
  return { builder: builder || null, independent };
913
940
  }
914
941
 
915
- 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 }) {
916
943
  if (!id) throw new Error('id required');
917
944
  const text = String(proof || '').trim();
918
945
  if (!text) throw new Error('proof required');
@@ -940,6 +967,15 @@ function readyTask(db, { id, actor, proof, lesson, nextTask, landing = {}, resul
940
967
  const cleaned = cleanLandingText(landingInput[key]);
941
968
  if (cleaned) metadata[`landing_${key}`] = cleaned;
942
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
+ }
943
979
  const passRecord = recordReviewPassActor(metadata, row.claimed_by, actor);
944
980
  if (reviewPassCount >= AGENT_CERTIFICATION_REVIEW_PASSES && passRecord.independent) {
945
981
  metadata.agent_certified = true;
@@ -1302,7 +1338,7 @@ function stageTask(db, {
1302
1338
  let stageOwner = requestedOwner || actorText || cleanStageText(row.claimed_by) || null;
1303
1339
  let goalText = requestedGoal || cleanStageText(metadata.task_goal || metadata.goal_objective || metadata.objective || metadata.stage_goal);
1304
1340
  let proofText = requestedProof || cleanStageText(metadata.verify || metadata.proof_needed);
1305
- let exitText = requestedExit || cleanStageText(metadata.exit_condition);
1341
+ let exitText = requestedExit || cleanStageText(metadata.exit_condition) || cleanStageText(explicitDoneLooksLike(metadata));
1306
1342
 
1307
1343
  function recordedDoPlan(currentRow, { claimedOwnerWins = false } = {}) {
1308
1344
  const currentMetadata = currentRow && currentRow.metadata && typeof currentRow.metadata === 'object'
@@ -1314,7 +1350,7 @@ function stageTask(db, {
1314
1350
  const caller = actorText || requestedOwner || null;
1315
1351
  const recordedGoal = cleanStageText(currentMetadata.goal_objective || currentMetadata.objective || currentMetadata.stage_goal || currentMetadata.task_goal);
1316
1352
  const recordedProof = cleanStageText(currentMetadata.verify || currentMetadata.proof_needed);
1317
- const recordedExit = cleanStageText(currentMetadata.exit_condition);
1353
+ const recordedExit = cleanStageText(currentMetadata.exit_condition) || cleanStageText(explicitDoneLooksLike(currentMetadata));
1318
1354
  const planRecorded = cleanStageText(currentMetadata.planned_at)
1319
1355
  || cleanStageText(currentMetadata.stage) === 'plan'
1320
1356
  || cleanStageText(currentMetadata.stage_plan_recorded_at);
@@ -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,
@@ -7,6 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const taskDb = require('./task-db');
9
9
  const { protectedLaneOf } = require('./auto-accept-certified');
10
+ const { isDecisionTask } = require('./task-decision');
10
11
 
11
12
  const DAY_MS = 24 * 60 * 60 * 1000;
12
13
  const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
@@ -72,6 +73,12 @@ function isProtectedRow(row) {
72
73
  return Boolean(protectedLaneOf({ ...row, metadata: parsedMetadata(row) }));
73
74
  }
74
75
 
76
+ // A task tagged needs-human or decision is waiting on a person, not stuck.
77
+ // Their silence is not an answer, so idle time never puts it away.
78
+ function isHeldForPerson(row) {
79
+ return isDecisionTask({ ...row, metadata: parsedMetadata(row) });
80
+ }
81
+
75
82
  function decisionFor(row, now, { includeDone = true } = {}) {
76
83
  if (!row || row.status === 'archived') return null;
77
84
  if (row.status === 'done') {
@@ -79,7 +86,7 @@ function decisionFor(row, now, { includeDone = true } = {}) {
79
86
  return { reason: REASONS.done, fromDone: true, fromFailed: false };
80
87
  }
81
88
  if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
82
- if (OPEN_STATUSES.has(row.status) && isProtectedRow(row)) return null;
89
+ if (OPEN_STATUSES.has(row.status) && (isProtectedRow(row) || isHeldForPerson(row))) return null;
83
90
  const at = Number(row.updated_at || row.created_at || 0);
84
91
  if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
85
92
  return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };