atris 3.70.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 (76) 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 +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 +359 -94
  20. package/commands/mission.js +249 -36
  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 +71 -17
  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 +108 -15
  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 +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
@@ -9,6 +9,7 @@ const autoland = require('../lib/autoland');
9
9
  const { gateForHuman, plainLandingReason } = require('../lib/voice-gate');
10
10
  const { evaluateAutoAccept } = require('../lib/auto-accept-certified');
11
11
  const { sweepEngineAskReceipts } = require('../lib/engine-receipt-sweep');
12
+ const { readSshAgents, findLeakedSshAgents, findAgentStartLines, agentStartLineText, stopLeakedSshAgents, closeLostRuns, readResourceHistory, errorReason, leakGrowthNote } = require('../lib/engine-resource');
12
13
  const { validateRecentAskReceipts } = require('../lib/engine-validate');
13
14
  const { operatorReady, hasAgentJargon, explainResult } = autoland;
14
15
  const MISSION_AUTO_VERIFY_STATUSES = new Set(['planning', 'paused', 'ready']);
@@ -358,8 +359,8 @@ function refreshLandingRefs(root) {
358
359
  }
359
360
  }
360
361
 
361
- function sweepLanding(root, { ttlDays, staleHours, now = Date.now() } = {}) {
362
- refreshLandingRefs(root);
362
+ function sweepLanding(root, { ttlDays, staleHours, now = Date.now(), refresh = true } = {}) {
363
+ if (refresh) refreshLandingRefs(root);
363
364
  const { collectBoard, reap } = require('./land');
364
365
  // light: this board only feeds .branches staleness + .summary; reap builds
365
366
  // its own full board for dirty-count salvage decisions.
@@ -479,7 +480,7 @@ function protectedReviewWaiting(root, policy = {}, waiting = []) {
479
480
  // lib/voice-gate.js (plainLandingReason); this alias keeps call sites short.
480
481
  const plainReason = plainLandingReason;
481
482
 
482
- function showStatus(root, args) {
483
+ function showStatus(root, args, deps = {}) {
483
484
  const json = args.includes('--json');
484
485
  const policy = autoland.readPolicy(root);
485
486
  const enabled = Boolean(policy && policy.enabled);
@@ -528,6 +529,45 @@ function showStatus(root, args) {
528
529
  if (policy && policy.imessage_to) console.log(` daily message: ${policy.imessage_to} at ${policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR}:00`);
529
530
  const reapTrouble = autoland.readState(root).last_reap_error;
530
531
  if (reapTrouble) console.log(` cleanup trouble: landing sweep failed on ${reapTrouble.date} (${reapTrouble.error}) - run: atris land --reap`);
532
+ const lost = readResourceHistory(root);
533
+ if (lost.read_error) console.log(` engine run log: could not read it (${lost.read_error}), so lost, killed, and failed runs in the last day are unknown`);
534
+ if (lost.lost_closed_day) {
535
+ const newest = lost.last_lost_closed;
536
+ const when = `${String(newest.at || '').slice(0, 16).replace('T', ' ')} UTC`;
537
+ console.log(` engine runs closed after their parent died in the last day: ${lost.lost_closed_day}, newest ${newest.engine || 'unknown engine'} on ${newest.task || 'no task'} at ${when}`);
538
+ }
539
+ if (lost.kills_day) {
540
+ // Timeouts are counted apart, and the newest kill named is the newest one
541
+ // that was not a timeout, so a night of timeouts cannot hide a crash.
542
+ const timeLimit = lost.time_limit_kills_day;
543
+ const counts = timeLimit === lost.kills_day
544
+ ? 'all at their time limit'
545
+ : `${timeLimit ? `${timeLimit} at their time limit, ` : ''}${lost.low_memory_kills_day} with machine memory low`;
546
+ const kill = lost.last_day_other_kill || lost.last_kill;
547
+ const label = timeLimit && lost.last_day_other_kill ? 'newest not at a time limit:' : 'newest';
548
+ const when = `${String(kill.at || '').slice(0, 16).replace('T', ' ')} UTC`;
549
+ const free = Number.isFinite(kill.free_pct) ? `, ${kill.free_pct}% memory free` : '';
550
+ console.log(` engine runs force-killed in the last day: ${lost.kills_day}, ${counts}; ${label} ${kill.engine || 'unknown engine'} on ${kill.task || 'no task'} at ${when}, ${kill.cause || 'cause unknown'}${free}`);
551
+ }
552
+ if (lost.errors_day) {
553
+ // Runs that failed with a message and were not killed, such as an engine
554
+ // that could not start; kills are counted on the line above.
555
+ const failed = lost.last_day_error;
556
+ const when = `${String(failed.at || '').slice(0, 16).replace('T', ' ')} UTC`;
557
+ console.log(` engine runs that ended in an error in the last day: ${lost.errors_day}, newest ${failed.engine || 'unknown engine'} on ${failed.task || 'no task'} at ${when}: ${errorReason(failed)}`);
558
+ }
559
+ const { leaked: leakedAgents, unread: unreadAgents } = countLeakedSshAgents(deps);
560
+ const unchecked = unreadAgents.length ? `could not read the ${unreadAgents.join(' or the ')}, so some ssh-agents went unchecked` : '';
561
+ if (leakedAgents) {
562
+ const stop = heartbeatLive ? 'the next hourly pass stops them' : 'stop them now: atris engine sweep --apply';
563
+ console.log(` leaked ssh-agents on this machine: ${unchecked ? 'at least ' : ''}${leakedAgents}${leakGrowthNote(lost.first_day_leak_reading, leakedAgents)}; ${stop}${unchecked ? `; ${unchecked}` : ''}`);
564
+ // A test run reads only the home it was handed, never the real one.
565
+ if (deps.home || !process.env.NODE_TEST_CONTEXT) {
566
+ for (const found of findAgentStartLines(deps.home ? { home: deps.home } : {})) console.log(` ${agentStartLineText(found)}`);
567
+ }
568
+ } else if (unchecked) {
569
+ console.log(` leaked ssh-agents on this machine: unknown, ${unchecked}`);
570
+ }
531
571
  console.log('');
532
572
  const humanOnly = blocked.filter((r) => String(r.reason || '').startsWith('denied_tag_'));
533
573
  const needsWork = blocked.filter((r) => !String(r.reason || '').startsWith('denied_tag_'));
@@ -747,11 +787,20 @@ function persistTickReceipt(root, receipt) {
747
787
  // Heartbeat liveness helpers live in lib/autoland.js so task ready copy and
748
788
  // status share one gate (recent tick receipt within two hours).
749
789
 
790
+ function acceptedNotLandedNote(receipt) {
791
+ const rows = Array.isArray(receipt.accepted_not_landed) ? receipt.accepted_not_landed : [];
792
+ if (!rows.length) return '';
793
+ return `, ${rows.length} accepted but not proven landed (${rows.map((r) => `${r.ref} ${r.status}`).join(', ')})`;
794
+ }
795
+
750
796
  function stuckBacklogNote(receipt) {
751
797
  const parts = [];
752
798
  const scanned = Number(receipt.scanned || 0);
753
799
  const certified = Number(receipt.certified || 0);
754
- if (scanned > 0 && receipt.landed.length === 0) {
800
+ // Stuck means nothing cleared review. Accepted-but-unproven work is named
801
+ // separately (accepted_not_landed), not misread as a wedged queue.
802
+ const accepted = Array.isArray(receipt.accepted) ? receipt.accepted.length : receipt.landed.length;
803
+ if (scanned > 0 && accepted === 0) {
755
804
  parts.push(`${certified}/${scanned} certified in review, none landed`);
756
805
  }
757
806
  const unrunnable = Array.isArray(receipt.unrunnable_verifiers) ? receipt.unrunnable_verifiers.length : 0;
@@ -773,6 +822,85 @@ function engineReceiptSweepNote(receipt) {
773
822
  return `, finalized ${finalized} stale engine receipt${finalized === 1 ? '' : 's'} as presumed dead`;
774
823
  }
775
824
 
825
+ // Read only: how many ssh-agents look leaked right now, and which readers
826
+ // failed, since then the count is only a floor. A test run never reads the
827
+ // real machine, and a thrown read counts as none.
828
+ function countLeakedSshAgents(deps = {}) {
829
+ const none = { leaked: 0, unread: [] };
830
+ try {
831
+ const read = deps.readSshAgents;
832
+ if (!read && process.env.NODE_TEST_CONTEXT) return none;
833
+ const uid = deps.uid !== undefined ? deps.uid : (typeof process.getuid === 'function' ? process.getuid() : null);
834
+ if (uid === null) return none;
835
+ const { agents, referenced, unread = [] } = (read || readSshAgents)();
836
+ return { leaked: findLeakedSshAgents(agents, { referenced, uid }).length, unread };
837
+ } catch {
838
+ return none;
839
+ }
840
+ }
841
+
842
+ // Shell startup files that start an ssh-agent per shell leave one behind
843
+ // for every engine shell. Each pass stops the ones a fresh recheck still
844
+ // calls leaked, and never lets a failed read break the pass.
845
+ function runSshAgentSweep(root, receipt, deps = {}) {
846
+ try {
847
+ const read = deps.read || readSshAgents;
848
+ const uid = deps.uid !== undefined ? deps.uid : (typeof process.getuid === 'function' ? process.getuid() : null);
849
+ const empty = { stopped: [], failed: [], skipped: [] };
850
+ // A test run must never stop real agents on the machine running it.
851
+ if (uid === null || (!deps.read && process.env.NODE_TEST_CONTEXT)) {
852
+ receipt.ssh_agent_sweep = empty;
853
+ return receipt.ssh_agent_sweep;
854
+ }
855
+ const { agents, referenced, unread = [] } = read();
856
+ const leaked = findLeakedSshAgents(agents, { referenced, uid });
857
+ const result = leaked.length
858
+ ? stopLeakedSshAgents(root, leaked, { read, uid, ...(deps.kill ? { kill: deps.kill } : {}) })
859
+ : empty;
860
+ // A reading that failed on either scan is kept, so the summary can say
861
+ // some agents went unchecked instead of reading as a clean machine.
862
+ const missed = [...new Set([...unread, ...(result.unread || [])])];
863
+ receipt.ssh_agent_sweep = missed.length ? { ...result, unread: missed } : result;
864
+ } catch (err) {
865
+ receipt.ssh_agent_sweep_error = String((err && err.message) || err).slice(0, 200);
866
+ }
867
+ return receipt.ssh_agent_sweep || null;
868
+ }
869
+
870
+ function sshAgentSweepNote(receipt) {
871
+ const stopped = (receipt.ssh_agent_sweep?.stopped || []).length;
872
+ const failed = (receipt.ssh_agent_sweep?.failed || []).length;
873
+ const replaced = (receipt.ssh_agent_sweep?.replaced || []).length;
874
+ const unread = receipt.ssh_agent_sweep?.unread || [];
875
+ const stoppedNote = stopped ? `, stopped ${stopped} leaked ssh-agent${stopped === 1 ? '' : 's'}` : '';
876
+ const failedNote = failed ? `, could not stop ${failed} leaked ssh-agent${failed === 1 ? '' : 's'}` : '';
877
+ const replacedNote = replaced ? `, left ${replaced} younger replacement ssh-agent${replaced === 1 ? '' : 's'} alone` : '';
878
+ const unreadNote = unread.length ? `, could not read the ${unread.join(' or the ')}, so some ssh-agents went unchecked` : '';
879
+ const scanErrorNote = receipt.ssh_agent_sweep_error ? ', could not scan ssh-agents, so leaked agents may remain' : '';
880
+ return stoppedNote + failedNote + replacedNote + unreadNote + scanErrorNote;
881
+ }
882
+
883
+ // An engine run whose dispatching process died never writes its own end.
884
+ // Each pass writes a "parent gone" end for those, so the log stops carrying
885
+ // them as open, and never lets a failed read break the pass.
886
+ function runLostRunSweep(root, receipt, deps = {}) {
887
+ try {
888
+ const close = deps.close || closeLostRuns;
889
+ const closed = close(root, deps.now ? { now: deps.now } : {});
890
+ receipt.lost_run_sweep = { closed: closed.map((end) => end.run_id) };
891
+ } catch (err) {
892
+ receipt.lost_run_sweep_error = String((err && err.message) || err).slice(0, 200);
893
+ }
894
+ return receipt.lost_run_sweep || null;
895
+ }
896
+
897
+ function lostRunSweepNote(receipt) {
898
+ const closed = (receipt.lost_run_sweep?.closed || []).length;
899
+ const closedNote = closed ? `, closed ${closed} lost engine run${closed === 1 ? '' : 's'}` : '';
900
+ const errorNote = receipt.lost_run_sweep_error ? ', could not close lost engine runs, so unfinished records may remain' : '';
901
+ return closedNote + errorNote;
902
+ }
903
+
776
904
  async function runEngineAnswerValidation(root, receipt, deps = {}) {
777
905
  try {
778
906
  receipt.engine_answer_validation = await validateRecentAskReceipts(root, deps);
@@ -796,6 +924,9 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
796
924
  receipt.engine_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
797
925
  }
798
926
 
927
+ runSshAgentSweep(root, receipt);
928
+ runLostRunSweep(root, receipt);
929
+
799
930
  await runEngineAnswerValidation(root, receipt, engineValidationDeps);
800
931
 
801
932
  // 1. certify and land in one task process. Keeping both phases together lets
@@ -806,6 +937,9 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
806
937
  // 78 certified rows waiting. Let `atris task auto-accept-certified` apply
807
938
  // its own default (12 without --all, a high safety cap under --all) so a
808
939
  // policy with accept_all:true actually drains the full certified set.
940
+ // Refresh origin once, before anything is accepted: the landing proof reads
941
+ // origin's target, and a stale ref would call landed work unlanded.
942
+ refreshLandingRefs(root);
809
943
  const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
810
944
  if (policy.accept_all) cliArgs.push('--all');
811
945
  else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
@@ -829,7 +963,21 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
829
963
  if (parsed.certification && parsed.certification.ok !== true) {
830
964
  receipt.certify_error = 'certify-verified failed';
831
965
  }
832
- receipt.landed = results.filter((r) => r.action === 'accepted').map((r) => r.ref);
966
+ // Accepted is a review verdict; landed is a git fact. Only work whose
967
+ // landing proof passed (lib/landing-proof.js) may be called landed. The
968
+ // rest is named with the reason, so an accept over an empty or unpushed
969
+ // commit reads as what it is instead of padding the landed count.
970
+ const acceptedRows = results.filter((r) => r.action === 'accepted');
971
+ receipt.accepted = acceptedRows.map((r) => r.ref);
972
+ receipt.landed = acceptedRows.filter((r) => r.landing && r.landing.status === 'landed').map((r) => r.ref);
973
+ const notLanded = acceptedRows
974
+ .filter((r) => !r.landing || r.landing.status !== 'landed')
975
+ .map((r) => ({
976
+ ref: r.ref,
977
+ status: (r.landing && r.landing.status) || 'unverified',
978
+ reason: (r.landing && r.landing.reason) || 'no_landing_proof',
979
+ }));
980
+ if (notLanded.length) receipt.accepted_not_landed = notLanded;
833
981
  const citationBlocks = results
834
982
  .filter((r) => r.action === 'skipped' && r.proof_state === 'suite_green_citation_required')
835
983
  .map((r) => ({ ref: r.ref, reason: r.reason }));
@@ -964,7 +1112,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
964
1112
  // TTL-expired work is salvaged before deletion, and 48h-stale active work
965
1113
  // is carried into the digest instead of silently aging forever.
966
1114
  try {
967
- const landingSweep = sweepLanding(root);
1115
+ const landingSweep = sweepLanding(root, { refresh: false });
968
1116
  receipt.reaped = landingSweep.reaped;
969
1117
  receipt.landing_sweep = {
970
1118
  before: landingSweep.before,
@@ -1035,6 +1183,22 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1035
1183
  receipt.wish_dispatch_error = String((err && err.message) || err).slice(0, 200);
1036
1184
  }
1037
1185
 
1186
+ // 3e. map fold, once an hour: map notes member changes saved as their own
1187
+ // files under atris/refs/map-notes.d/ are folded into the shared map notes
1188
+ // on main and removed, in one pushed commit. Off by policy: map_fold:false.
1189
+ if (policy.map_fold !== false) {
1190
+ try {
1191
+ const { foldDue, foldMapNotesOnMain } = require('../lib/member-map-fold');
1192
+ if (foldDue(state.last_map_fold_at)) {
1193
+ const fold = foldMapNotesOnMain(root);
1194
+ receipt.map_fold = { folded: fold.folded, added: fold.added || 0, reason: fold.reason || null };
1195
+ if (fold.reason !== 'main_unreadable') state.last_map_fold_at = new Date().toISOString();
1196
+ }
1197
+ } catch (err) {
1198
+ receipt.map_fold_error = String((err && err.message) || err).slice(0, 200);
1199
+ }
1200
+ }
1201
+
1038
1202
  // 4. daily digest at the configured hour
1039
1203
  const today = new Date().toISOString().slice(0, 10);
1040
1204
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
@@ -1148,7 +1312,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1148
1312
  // Always print the standing backlog, and name work that cleared every
1149
1313
  // gate and still did not land. That is the shape of a stuck loop.
1150
1314
  const stuckNote = stuckBacklogNote(receipt);
1151
- const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${receiptNote}`;
1315
+ const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
1152
1316
  const validationLine = engineAnswerValidationLine(receipt);
1153
1317
  if (validationLine) console.log(validationLine);
1154
1318
  console.log(gateForHuman(summary).text);
@@ -1221,6 +1385,11 @@ module.exports = {
1221
1385
  hasAgentJargon,
1222
1386
  runEngineAnswerValidation,
1223
1387
  runTickBody,
1388
+ runSshAgentSweep,
1389
+ sshAgentSweepNote,
1390
+ showStatus,
1391
+ runLostRunSweep,
1392
+ lostRunSweepNote,
1224
1393
  sweepLanding,
1225
1394
  verifyClosedTaskMissions,
1226
1395
  };
@@ -119,7 +119,7 @@ function repoMapAuditReportsClean(cwd) {
119
119
  * Scan workspace for the next thing worth doing.
120
120
  * Returns { task, why, kind } or null.
121
121
  */
122
- async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {}) {
122
+ async function suggestNextTask(cwd, skipped = new Set(), { auto = false, problems = null } = {}) {
123
123
  const atrisDir = path.join(cwd, 'atris');
124
124
  const suggestions = [];
125
125
 
@@ -344,7 +344,9 @@ async function suggestNextTask(cwd, skipped = new Set(), { auto = false } = {})
344
344
  kind: 'imagined',
345
345
  priority: 99
346
346
  };
347
- } catch {
347
+ } catch (err) {
348
+ // An engine that never answered is not an idle workspace; tell the caller.
349
+ if (problems && err && err.launchFailed) problems.push(String(err.message || '').split('\n')[0]);
348
350
  return null;
349
351
  }
350
352
  }
@@ -607,6 +609,8 @@ function executePhaseDetailed(phase, context, options = {}) {
607
609
 
608
610
  let rosterRunner = null;
609
611
  let startedMs = Date.now();
612
+ // Prep writes its own pair of readings inside the pass, before this launch's.
613
+ let endResource = () => null;
610
614
  try {
611
615
  let cmd = options.cmdOverride;
612
616
  if (!cmd) {
@@ -638,6 +642,12 @@ function executePhaseDetailed(phase, context, options = {}) {
638
642
  const env = { ...process.env };
639
643
  delete env.CLAUDECODE;
640
644
  startedMs = Date.now();
645
+ // Machine memory and load at both ends of the launch, so a phase killed
646
+ // with SIGKILL says whether the machine was short on memory.
647
+ endResource = require('../lib/engine-resource').startEngineRun(process.cwd(), {
648
+ engine: (rosterRunner && rosterRunner.engineId) || 'runner',
649
+ task: `autopilot ${phase}`,
650
+ });
641
651
  const output = execPhaseCommandSync(cmd, {
642
652
  cwd: process.cwd(),
643
653
  encoding: 'utf8',
@@ -648,10 +658,12 @@ function executePhaseDetailed(phase, context, options = {}) {
648
658
  });
649
659
 
650
660
  try { fs.unlinkSync(tmpFile); } catch {}
661
+ endResource();
651
662
  recordPhaseRosterRun(process.cwd(), phase, rosterRunner, startedMs, { outcome: 'landed', stdout: output || '' });
652
663
  return { prompt, output: output || '' };
653
664
  } catch (err) {
654
665
  try { fs.unlinkSync(tmpFile); } catch {}
666
+ const resourceEnd = endResource(err);
655
667
  recordPhaseRosterRun(process.cwd(), phase, rosterRunner, startedMs, phaseFailureOutcome(err, rosterRunner), err);
656
668
  if (isPhaseTimeoutError(err)) {
657
669
  // A roster worker that stalls sits out the cooldown, so the next
@@ -664,7 +676,8 @@ function executePhaseDetailed(phase, context, options = {}) {
664
676
  throw new Error(`${phase} phase timed out after ${timeout / 1000}s (configured runner hit the wall; any work it committed survives, reconcile from pre-tick HEADs)`);
665
677
  }
666
678
  if (isPhaseKillError(err)) {
667
- throw new Error(`${phase} phase killed by ${err.signal || 'a signal'} before the ${timeout / 1000}s wall, not a timeout; check memory pressure or an external supervisor`);
679
+ const cause = resourceEnd && resourceEnd.cause ? `${resourceEnd.cause}; ` : '';
680
+ throw new Error(`${phase} phase killed by ${err.signal || 'a signal'} before the ${timeout / 1000}s wall, not a timeout; ${cause}check memory pressure or an external supervisor`);
668
681
  }
669
682
  if (err.stdout) {
670
683
  return { prompt, output: err.stdout };
@@ -1525,6 +1538,7 @@ function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1525
1538
  fs.writeFileSync(tmpFile, prompt);
1526
1539
  let runner = null;
1527
1540
  let startedMs = Date.now();
1541
+ let endResource = () => null;
1528
1542
  try {
1529
1543
  runner = buildPlanReviewRunner(tmpFile, cwd, timeout);
1530
1544
  const cmd = runner.command;
@@ -1532,6 +1546,12 @@ function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1532
1546
  const env = { ...process.env };
1533
1547
  delete env.CLAUDECODE;
1534
1548
  startedMs = Date.now();
1549
+ // Machine memory and load at both ends of the review, so one killed with
1550
+ // SIGKILL says whether the machine was short on memory.
1551
+ endResource = require('../lib/engine-resource').startEngineRun(cwd, {
1552
+ engine: (runner && runner.engineId) || 'runner',
1553
+ task: 'autopilot plan review',
1554
+ });
1535
1555
  const output = execPhaseCommandSync(cmd, {
1536
1556
  cwd,
1537
1557
  encoding: 'utf8',
@@ -1540,11 +1560,15 @@ function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1540
1560
  maxBuffer: 10 * 1024 * 1024,
1541
1561
  env,
1542
1562
  });
1563
+ endResource();
1543
1564
  recordPhaseRosterRun(cwd, 'plan review', runner, startedMs, { outcome: 'landed', stdout: output || '' });
1544
1565
  return output || '';
1545
1566
  } catch (err) {
1567
+ const end = endResource(err);
1546
1568
  recordPhaseRosterRun(cwd, 'plan review', runner, startedMs, phaseFailureOutcome(err, runner), err);
1547
1569
  if (err.stdout) return err.stdout;
1570
+ // A kill leaves no report of its own, so the error names the likely cause.
1571
+ if (end && end.cause && err instanceof Error) err.message = `${err.message}; ${end.cause}`;
1548
1572
  throw err;
1549
1573
  } finally {
1550
1574
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -1557,6 +1581,9 @@ function defaultPlanReviewExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1557
1581
  */
1558
1582
  function defaultCodexExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1559
1583
  const cmd = process.env.ATRIS_CODEX_CMD || 'codex';
1584
+ // Machine memory and load at both ends of the signer, so one killed with
1585
+ // SIGKILL says whether the machine was short on memory.
1586
+ const endResource = require('../lib/engine-resource').startEngineRun(cwd, { engine: 'codex', task: 'autopilot codex review' });
1560
1587
  const proc = spawnSync(cmd, ['-p', prompt], {
1561
1588
  cwd,
1562
1589
  encoding: 'utf8',
@@ -1575,8 +1602,18 @@ function defaultCodexExecutor(prompt, { cwd, timeout = 180000 } = {}) {
1575
1602
  if (sweepErr.code !== 'ESRCH') throw sweepErr;
1576
1603
  }
1577
1604
  }
1605
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
1606
+ const failed = proc.error || proc.signal || proc.status !== 0;
1607
+ const end = endResource(failed ? {
1608
+ code: proc.error && proc.error.code,
1609
+ signal: proc.signal,
1610
+ stderr: proc.stderr,
1611
+ message: proc.error ? proc.error.message : `codex exited with status ${proc.status}`,
1612
+ } : null);
1613
+ const cause = end && end.cause ? `; ${end.cause}` : '';
1578
1614
  if (proc.status !== 0 && !proc.stdout) {
1579
- throw new Error(`codex exited with status ${proc.status}: ${proc.stderr || 'no output'}`);
1615
+ const how = proc.signal ? `was stopped by ${proc.signal}` : `exited with status ${proc.status}`;
1616
+ throw new Error(`codex ${how}: ${proc.stderr || 'no output'}${cause}`);
1580
1617
  }
1581
1618
  return proc.stdout || '';
1582
1619
  }
@@ -3268,10 +3305,14 @@ Reply with the JSON array and nothing else.`;
3268
3305
  fs.writeFileSync(tmpFile, prompt);
3269
3306
 
3270
3307
  let output = '';
3308
+ let endResource = () => null;
3271
3309
  try {
3272
3310
  const cmd = buildRunnerCommand({ promptFile: tmpFile });
3273
3311
  const env = { ...process.env };
3274
3312
  delete env.CLAUDECODE;
3313
+ // Machine memory and load at both ends of the proposal, so one killed
3314
+ // with SIGKILL says whether the machine was short on memory.
3315
+ endResource = require('../lib/engine-resource').startEngineRun(cwd, { engine: 'runner', task: 'horizon proposal' });
3275
3316
  output = execPhaseCommandSync(cmd, {
3276
3317
  cwd,
3277
3318
  encoding: 'utf8',
@@ -3280,13 +3321,18 @@ Reply with the JSON array and nothing else.`;
3280
3321
  maxBuffer: 10 * 1024 * 1024,
3281
3322
  env
3282
3323
  }).toString();
3324
+ endResource();
3283
3325
  } catch (err) {
3326
+ const end = endResource(err);
3327
+ // launchFailed marks errors where the engine never answered.
3284
3328
  if (isPhaseTimeoutError(err)) {
3285
- throw new Error(`horizon-proposal phase timed out after ${PHASE_TIMEOUT / 1000}s`);
3329
+ throw Object.assign(new Error(`horizon-proposal phase timed out after ${PHASE_TIMEOUT / 1000}s`), { launchFailed: true });
3286
3330
  }
3287
3331
  if (isPhaseKillError(err)) {
3288
- throw new Error(`horizon-proposal phase killed by ${err.signal || 'a signal'} before the ${PHASE_TIMEOUT / 1000}s wall, not a timeout`);
3332
+ const cause = end && end.cause ? `; ${end.cause}` : '';
3333
+ throw Object.assign(new Error(`horizon-proposal phase killed by ${err.signal || 'a signal'} before the ${PHASE_TIMEOUT / 1000}s wall, not a timeout${cause}`), { launchFailed: true });
3289
3334
  }
3335
+ if (err && typeof err === 'object') err.launchFailed = true;
3290
3336
  throw err;
3291
3337
  } finally {
3292
3338
  try { fs.unlinkSync(tmpFile); } catch {}
@@ -3470,18 +3516,23 @@ async function autopilotAtris(description, options = {}) {
3470
3516
  break;
3471
3517
  }
3472
3518
 
3473
- const suggestion = await suggestNextTask(cwd, skipped, { auto });
3519
+ const problems = [];
3520
+ const suggestion = await suggestNextTask(cwd, skipped, { auto, problems });
3474
3521
 
3475
3522
  if (!suggestion) {
3523
+ // A failed proposal launch must not read as a clean workspace.
3524
+ const proposalFailure = problems[0] ? `I could not ask the engine for new work: ${problems[0]}` : '';
3476
3525
  tickOutcome = 'idle';
3477
- tickOutcomeText = 'I checked the repo and found no work to pick up this tick.';
3526
+ tickOutcomeText = proposalFailure
3527
+ ? `I found no work to pick up this tick. ${proposalFailure}`
3528
+ : 'I checked the repo and found no work to pick up this tick.';
3478
3529
  tickNextStep = 'scan for new signals and propose the next horizon';
3479
3530
  if (verbose) {
3480
- console.log(' nothing to do. workspace is clean.');
3531
+ console.log(proposalFailure ? ` nothing to do. ${proposalFailure}` : ' nothing to do. workspace is clean.');
3481
3532
  } else {
3482
3533
  printPlainBlock([
3483
3534
  'I found no work this tick.',
3484
- 'The workspace looks clean.',
3535
+ proposalFailure || 'The workspace looks clean.',
3485
3536
  '',
3486
3537
  'Next I will stop until a new signal appears.'
3487
3538
  ].join('\n'));
@@ -3956,10 +4007,14 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
3956
4007
  const tmpFile = path.join(cwd, '.staleness-prompt.tmp');
3957
4008
  fs.writeFileSync(tmpFile, prompt);
3958
4009
 
4010
+ let endResource = () => null;
3959
4011
  try {
3960
4012
  const env = { ...process.env };
3961
4013
  delete env.CLAUDECODE;
3962
4014
  const cmd = buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Bash,Read,Glob,Grep' });
4015
+ // Machine memory and load at both ends of the check, so one killed with
4016
+ // SIGKILL says whether the machine was short on memory.
4017
+ endResource = require('../lib/engine-resource').startEngineRun(cwd, { engine: 'runner', task: 'staleness check' });
3963
4018
  const output = execPhaseCommandSync(cmd, {
3964
4019
  cwd,
3965
4020
  encoding: 'utf8',
@@ -3968,6 +4023,7 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
3968
4023
  maxBuffer: 2 * 1024 * 1024,
3969
4024
  env
3970
4025
  }).trim();
4026
+ endResource();
3971
4027
 
3972
4028
  try { fs.unlinkSync(tmpFile); } catch {}
3973
4029
 
@@ -3980,7 +4036,10 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
3980
4036
  } catch (err) {
3981
4037
  try { fs.unlinkSync(tmpFile); } catch {}
3982
4038
  // On timeout or crash, treat as unverifiable, conservative default
3983
- return { fresh: false, reasoning: `Model check failed: ${(err.message || '').slice(0, 100)}` };
4039
+ // A kill leaves no report of its own, so the reason names the likely cause.
4040
+ const end = endResource(err);
4041
+ const cause = end && end.cause ? `; ${end.cause}` : '';
4042
+ return { fresh: false, reasoning: `Model check failed: ${(err.message || '').slice(0, 100)}${cause}` };
3984
4043
  }
3985
4044
  }
3986
4045
 
@@ -3992,6 +4051,7 @@ async function autopilotFromTodo(options = {}) {
3992
4051
  }
3993
4052
 
3994
4053
  module.exports = {
4054
+ askModel,
3995
4055
  snapshotRepoHeads,
3996
4056
  diffAdvancedRepoHeads,
3997
4057
  reconcileTimedOutTick,