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
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const os = require('os');
5
6
  const crypto = require('crypto');
6
7
  const readline = require('readline');
7
8
  const { spawn, spawnSync } = require('child_process');
@@ -115,6 +116,8 @@ const {
115
116
 
116
117
  const VALID_STATUSES = new Set(['planning', 'running', 'ready', 'paused', 'blocked', 'stopped', 'complete']);
117
118
  const TERMINAL_STATUSES = new Set(['stopped', 'complete']);
119
+ // Campaign hooks load on first use, so missions without a campaign never open its store.
120
+ const campaignMissions = () => require('../lib/campaign-missions');
118
121
  const GOAL_LOOP_STATUSES = new Set(['planning', 'running', 'ready']);
119
122
  const STATUS_ALIASES = new Set(['active']);
120
123
  const CODEX_NATIVE_GOAL_SLOT_STATUSES = new Set(['active', 'paused', 'usage_limited']);
@@ -425,11 +428,30 @@ async function missionTickPrep(root, { mission, runtimeMission, job, prompt, ask
425
428
 
426
429
  // A tick's time limit: the time left on the mission, capped by the roster
427
430
  // line's "max 20 min" when it sets one, else by the default tick cap.
428
- function missionTickTimeoutMs(mission, remainingMs) {
429
- const cap = Number(mission && mission.roster_max_seconds) > 0
431
+ function missionTickCapMs(mission) {
432
+ return Number(mission && mission.roster_max_seconds) > 0
430
433
  ? Number(mission.roster_max_seconds) * 1000
431
434
  : MISSION_RUN_DEFAULTS.claudeTimeoutMs;
432
- return clampTimeoutMs(remainingMs, cap);
435
+ }
436
+
437
+ function missionTickTimeoutMs(mission, remainingMs) {
438
+ return clampTimeoutMs(remainingMs, missionTickCapMs(mission));
439
+ }
440
+
441
+ // True when the time left on the mission, not the tick's own cap, set the
442
+ // tick's limit, so a kill there means the mission clock ran out.
443
+ function missionTickWallBound(mission, remainingMs) {
444
+ return remainingMs <= missionTickCapMs(mission);
445
+ }
446
+
447
+ // The reason a member log shows for a tick. A failed engine tick names its
448
+ // engine beside the reason, so a codex run that timed out stops counting as
449
+ // a claude timeout in the repeat-problem scan. The reason itself is left as
450
+ // is because engine health and backoff match on it.
451
+ function memberLogTickReason(result) {
452
+ const reason = result && result.reason;
453
+ if (!reason || !result.engine_id || result.status !== 'errored') return reason;
454
+ return `${reason} (engine: ${result.engine_id})`;
433
455
  }
434
456
 
435
457
  function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
@@ -742,6 +764,8 @@ const MISSION_RUN_VALUE_FLAGS = [
742
764
  '--engine',
743
765
  '--referee-engine',
744
766
  '--budget',
767
+ '--campaign',
768
+ '--reserve',
745
769
  '--repo',
746
770
  '--native-goal-status',
747
771
  '--native-goal-objective',
@@ -1478,14 +1502,24 @@ function nearestMissionCandidates(missions, ref, limit = 5) {
1478
1502
  .map((entry) => entry.mission);
1479
1503
  }
1480
1504
 
1481
- function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5) {
1505
+ // Sibling folders looked at for a hint. A missing-id error waited 26 seconds
1506
+ // on a temp folder holding 276,000 entries, so the scan is capped and never
1507
+ // walks the system temp folder.
1508
+ const SIBLING_HINT_SCAN_LIMIT = 2000;
1509
+
1510
+ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5, { scanLimit = SIBLING_HINT_SCAN_LIMIT, tmpRoot = os.tmpdir() } = {}) {
1482
1511
  if (!ref) return [];
1483
1512
  const baseRoot = path.resolve(root);
1484
1513
  const parent = path.dirname(baseRoot);
1485
- let here = baseRoot;
1486
- try {
1487
- here = fs.realpathSync(baseRoot);
1488
- } catch { /* keep raw path */ }
1514
+ const real = (target) => {
1515
+ try {
1516
+ return fs.realpathSync(target);
1517
+ } catch {
1518
+ return target;
1519
+ }
1520
+ };
1521
+ const here = real(baseRoot);
1522
+ if (real(parent) === real(tmpRoot)) return [];
1489
1523
  let entries = [];
1490
1524
  try {
1491
1525
  entries = fs.readdirSync(parent, { withFileTypes: true });
@@ -1493,17 +1527,11 @@ function listSiblingWorkspaceMissionHints(ref, root = process.cwd(), limit = 5)
1493
1527
  return [];
1494
1528
  }
1495
1529
  const hints = [];
1496
- for (const entry of entries) {
1530
+ for (const entry of entries.slice(0, scanLimit)) {
1497
1531
  if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
1498
1532
  const candidateRoot = path.join(parent, entry.name);
1499
- let candidateReal = candidateRoot;
1500
- try {
1501
- candidateReal = fs.realpathSync(candidateRoot);
1502
- } catch {
1503
- continue;
1504
- }
1505
- if (candidateReal === here) continue;
1506
1533
  if (!fs.existsSync(path.join(candidateRoot, '.atris', 'state', 'missions.jsonl'))) continue;
1534
+ if (real(candidateRoot) === here) continue;
1507
1535
  const mission = listMissions(candidateRoot).find((row) => missionMatchesRef(row, ref));
1508
1536
  if (!mission) continue;
1509
1537
  hints.push({
@@ -1696,6 +1724,7 @@ function saveMission(mission, root = process.cwd(), eventType = 'mission_updated
1696
1724
  // this file several times, so unbounded snapshot history taxes everything.
1697
1725
  try { compactMissionLedger(paths.missionsJsonl); } catch {}
1698
1726
  const event = appendEvent(eventType, next, payload, root);
1727
+ if (next.campaign_id) campaignMissions().recordCampaignMissionEnded(root, existing, next, TERMINAL_STATUSES);
1699
1728
  renderMissionStatus(root);
1700
1729
  renderMemberMissionState(next.owner, root);
1701
1730
  return { mission: next, event };
@@ -3169,6 +3198,9 @@ function missionFromArgs(args) {
3169
3198
  '--base',
3170
3199
  '--task',
3171
3200
  '--ask',
3201
+ '--campaign',
3202
+ '--reserve',
3203
+ '--mission-id',
3172
3204
  '--model',
3173
3205
  '--destination',
3174
3206
  '--native-goal-status',
@@ -3185,6 +3217,7 @@ function missionFromArgs(args) {
3185
3217
  const budgetContract = inferRunObjectiveBudgetContract(objective, args);
3186
3218
  const maxTicksOverride = readPositiveIntegerFlag(args, '--max-ticks', null, { json: wantsJson(args) });
3187
3219
  const requestedOwner = readFlag(args, '--owner', process.env.ATRIS_AGENT_ID || 'mission-lead');
3220
+ const campaignId = readFlag(args, '--campaign', '');
3188
3221
  const cadence = readFlag(args, '--cadence', readFlag(args, '--loop', 'manual')) || 'manual';
3189
3222
  const engineFlag = readFlag(args, '--engine', '');
3190
3223
  const runnerSelection = engineFlag
@@ -3212,7 +3245,13 @@ function missionFromArgs(args) {
3212
3245
  const alwaysOn = hasFlag(args, '--always-on');
3213
3246
  const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
3214
3247
  const businessBinding = readBusinessBinding(process.cwd());
3215
- const id = missionId(objective);
3248
+ // A campaign proposes a mission under an id before it runs; --mission-id
3249
+ // keeps that id so the campaign and the mission name the same thing.
3250
+ const givenId = readFlag(args, '--mission-id', '');
3251
+ if (givenId && !/^[a-z0-9][a-z0-9-]{2,120}$/.test(givenId)) {
3252
+ exitMissionError('--mission-id takes lowercase letters, digits, and dashes', 2, wantsJson(args));
3253
+ }
3254
+ const id = givenId || missionId(objective);
3216
3255
  const mission = {
3217
3256
  schema: 'atris.mission.v1',
3218
3257
  id,
@@ -3238,6 +3277,7 @@ function missionFromArgs(args) {
3238
3277
  stop_condition: stopCondition,
3239
3278
  task_ids: taskIds,
3240
3279
  human_asks: humanAsks,
3280
+ ...(campaignId ? { campaign_id: campaignId } : {}),
3241
3281
  ...(drive ? { drive } : {}),
3242
3282
  next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
3243
3283
  receipt_path: null,
@@ -4351,6 +4391,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4351
4391
  ...mission,
4352
4392
  started_from: 'mission_run_continuation',
4353
4393
  parent_mission_id: parent.id,
4394
+ ...(parent.campaign_id && campaignMissions().checkCampaignStart(root, parent.campaign_id, { reserveUsd: 0 }).ok ? { campaign_id: parent.campaign_id } : {}),
4354
4395
  parent_objective: parent.objective,
4355
4396
  continue_on_complete: false,
4356
4397
  continuation_policy: 'choose_next_mission',
@@ -4367,6 +4408,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4367
4408
  parent_objective: parent.objective,
4368
4409
  proof: proof || null,
4369
4410
  });
4411
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(root, saved, 0);
4370
4412
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, root);
4371
4413
  const seededAt = stampIso();
4372
4414
  const { mission: updatedParent } = saveMission({
@@ -4452,7 +4494,7 @@ function startMission(args, options = {}) {
4452
4494
  const asJson = wantsJson(args);
4453
4495
  const firstArg = String(args[0] || '').trim().toLowerCase();
4454
4496
  if (args.length === 1 && (firstArg === '--help' || firstArg === '-h' || firstArg === 'help')) {
4455
- console.log('Usage: atris mission start "<objective>" --owner <member> --verify "..." [--destination "..."] [--no-verify] [--always-on] [--budget quick|long|deep] [--runner manual|claude|atris2|codex_goal|auto]');
4497
+ console.log('Usage: atris mission start "<objective>" --owner <member> --verify "..." [--destination "..."] [--no-verify] [--always-on] [--budget quick|long|deep] [--runner manual|claude|atris2|codex_goal|auto] [--campaign <id> [--reserve <dollars>]]');
4456
4498
  console.log('Run `atris mission --help` for the full option list.');
4457
4499
  process.exit(0);
4458
4500
  }
@@ -4539,6 +4581,18 @@ function startMission(args, options = {}) {
4539
4581
  asJson,
4540
4582
  );
4541
4583
  }
4584
+ // --campaign: the campaign must be open and able to hold this mission's
4585
+ // reserve. Checked before any state exists, so a refusal leaves nothing behind.
4586
+ let campaignReserve = 0;
4587
+ if (mission.campaign_id) {
4588
+ const check = campaignMissions().checkCampaignStart(process.cwd(), mission.campaign_id, { missionId: mission.id, reserveUsd: readFlag(args, '--reserve', '') });
4589
+ if (!check.ok) exitMissionError(`mission start refused: ${check.error}`, 2, asJson);
4590
+ try {
4591
+ campaignMissions().reserveCampaignMission(process.cwd(), mission.campaign_id, mission.id, check.reserve, mission.owner || 'mission-lead');
4592
+ } catch (error) {
4593
+ exitMissionError(`mission start refused: ${error.message}`, 2, asJson);
4594
+ }
4595
+ }
4542
4596
  // --worktree: bind the mission to its own isolated checkout. We chdir before
4543
4597
  // any state writes so the mission record, baseline sidecar, receipts, and
4544
4598
  // member files all land inside the worktree, ticks run there, and the main
@@ -4630,6 +4684,7 @@ function startMission(args, options = {}) {
4630
4684
  const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
4631
4685
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
4632
4686
  const { mission: saved } = saveMission(assignMissionNumber(mission, process.cwd()), process.cwd(), 'mission_started', { objective: mission.objective });
4687
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(process.cwd(), saved, campaignReserve);
4633
4688
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
4634
4689
  ? takeCodexGoalSlotForMission(saved, process.cwd())
4635
4690
  : null;
@@ -4750,6 +4805,8 @@ async function startMissionFromRunObjective(objective, args) {
4750
4805
  ];
4751
4806
  if (verifier) startArgs.push('--verify', verifier);
4752
4807
  if (budgetTier) startArgs.push('--budget', budgetTier.name);
4808
+ const campaignFlag = readFlag(args, '--campaign', '');
4809
+ if (campaignFlag) startArgs.push('--campaign', campaignFlag);
4753
4810
  if (maxTicksOverride) startArgs.push('--max-ticks', String(maxTicksOverride));
4754
4811
  const model = readFlag(args, '--model', '');
4755
4812
  if (model) startArgs.push('--model', model);
@@ -4757,6 +4814,16 @@ async function startMissionFromRunObjective(objective, args) {
4757
4814
  if (hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp') || missionRunPreflight?.task_spine_required) startArgs.push('--xp-task');
4758
4815
 
4759
4816
  const mission = markMissionRunContinuation(missionFromArgs(startArgs));
4817
+ let campaignReserve = 0;
4818
+ if (mission.campaign_id) {
4819
+ const check = campaignMissions().checkCampaignStart(process.cwd(), mission.campaign_id, { missionId: mission.id, reserveUsd: readFlag(args, '--reserve', '') });
4820
+ if (!check.ok) exitMissionError(`mission start refused: ${check.error}`, 2, wantsJson(args));
4821
+ try {
4822
+ campaignMissions().reserveCampaignMission(process.cwd(), mission.campaign_id, mission.id, check.reserve, mission.owner || 'mission-lead');
4823
+ } catch (error) {
4824
+ exitMissionError(`mission start refused: ${error.message}`, 2, wantsJson(args));
4825
+ }
4826
+ }
4760
4827
  if (landOwnerResolution) {
4761
4828
  mission.owner = runOwner;
4762
4829
  mission.owner_resolution = landOwnerResolution.reason || mission.owner_resolution;
@@ -4840,6 +4907,7 @@ async function startMissionFromRunObjective(objective, args) {
4840
4907
  const warnings = [missingVerifierWarning(mission), missingOwnerMemberWarning(mission.owner)].filter(Boolean);
4841
4908
  ensureMemberMissionFile(mission.owner, process.cwd(), mission.objective);
4842
4909
  const { mission: saved } = saveMission(assignMissionNumber(mission, process.cwd()), process.cwd(), 'mission_started', { objective: mission.objective, source: 'mission_run_objective' });
4910
+ if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(process.cwd(), saved, campaignReserve);
4843
4911
  if (landRun) {
4844
4912
  const memberState = renderMemberMissionState(saved.owner);
4845
4913
  const logPath = appendMemberLog(saved.owner, 'Mission landing flight dispatched', {
@@ -6597,7 +6665,7 @@ function runVerifier(command, root = process.cwd()) {
6597
6665
  const resolvedCommand = resolveVerifierCommand(command);
6598
6666
  const { envWithNodeDir } = require('../lib/spawn-env');
6599
6667
  const timeoutMs = missionVerifierTimeoutMs();
6600
- const startedAt = Date.now();
6668
+ const startedAt = process.hrtime.bigint();
6601
6669
  const result = spawnSync(resolvedCommand, {
6602
6670
  cwd: root,
6603
6671
  shell: true,
@@ -6605,11 +6673,15 @@ function runVerifier(command, root = process.cwd()) {
6605
6673
  timeout: timeoutMs,
6606
6674
  env: envWithNodeDir(process.env),
6607
6675
  });
6608
- const elapsedMs = Date.now() - startedAt;
6609
6676
  // A verifier that blows its window is not a failing check: the code may be
6610
6677
  // fine and the suite merely slow (or starved by a concurrent flight). Report
6611
6678
  // it as a distinct timed-out state so operators do not read slow as broken.
6612
6679
  const timedOut = result.error ? result.error.code === 'ETIMEDOUT' : false;
6680
+ // Steady clock, not wall time: the wall clock can slew, and a kill at the
6681
+ // window once read as 927ms of a 1000ms window. A timed-out run used the
6682
+ // whole window by definition.
6683
+ const measuredMs = Number((process.hrtime.bigint() - startedAt) / 1000000n);
6684
+ const elapsedMs = timedOut ? Math.max(measuredMs, timeoutMs) : measuredMs;
6613
6685
  return {
6614
6686
  command,
6615
6687
  resolved_command: resolvedCommand === command ? null : resolvedCommand,
@@ -6629,17 +6701,29 @@ const CHECK_FEEDBACK_TRIM_MARKER = 'output trimmed';
6629
6701
 
6630
6702
  function extractCheckFeedback(verifierResult) {
6631
6703
  if (!verifierResult || verifierResult.passed !== false) return '';
6632
- const lines = [verifierResult.output, verifierResult.stdout, verifierResult.stderr]
6704
+ // A reviewer turn-down goes first: when the tests passed, it is the only
6705
+ // real reason the try failed, and passing test names must not crowd it out.
6706
+ const referee = verifierResult.referee && verifierResult.referee.passed === false
6707
+ ? verifierResult.referee
6708
+ : null;
6709
+ const refereeLines = referee
6710
+ ? [referee.reason, referee.output].filter((value) => String(value || '').trim())
6711
+ : [];
6712
+ const lines = [...new Set([...refereeLines, verifierResult.output, verifierResult.stdout, verifierResult.stderr]
6633
6713
  .filter((value) => String(value || '').trim())
6634
6714
  .flatMap((value) => String(value).split(/\r?\n/))
6635
6715
  .map((line) => line.trim())
6636
- .filter(Boolean);
6716
+ .filter(Boolean))];
6637
6717
  if (!lines.length) return '';
6638
6718
 
6719
+ const refereeSet = new Set(refereeLines.flatMap((value) => String(value).split(/\r?\n/)).map((line) => line.trim()).filter(Boolean));
6720
+ const passingLine = /^(?:✔|ℹ )/;
6639
6721
  const usefulPattern = /\b(?:error|fail|expected|assert)\w*\b|(?:^|\s|\()[^:\s()]+:\d+(?::\d+)?(?:\b|$)/i;
6640
- const useful = lines.filter((line) => usefulPattern.test(line));
6641
- const context = lines.filter((line) => !usefulPattern.test(line));
6642
- const feedback = [...useful, ...context].join('\n');
6722
+ const rest = lines.filter((line) => !refereeSet.has(line));
6723
+ const useful = rest.filter((line) => !passingLine.test(line) && usefulPattern.test(line));
6724
+ const context = rest.filter((line) => !passingLine.test(line) && !usefulPattern.test(line));
6725
+ const passing = rest.filter((line) => passingLine.test(line));
6726
+ const feedback = [...lines.filter((line) => refereeSet.has(line)), ...useful, ...context, ...passing].join('\n');
6643
6727
  if (feedback.length <= CHECK_FEEDBACK_LIMIT) return feedback;
6644
6728
 
6645
6729
  const contentLimit = CHECK_FEEDBACK_LIMIT - CHECK_FEEDBACK_TRIM_MARKER.length - 1;
@@ -6878,7 +6962,10 @@ function worktreeReceipt(before, after, { verifier = '', baseline = null } = {})
6878
6962
  const MISSION_RUN_DEFAULTS = {
6879
6963
  maxTicks: 4,
6880
6964
  maxWallSeconds: 3600,
6881
- claudeTimeoutMs: 10 * 60 * 1000,
6965
+ // Sized from real runs: claude ticks that landed 10/08-10/14 ran a median
6966
+ // 4 min, but 15 of 92 needed more than 10 and the slowest 5% took 17+.
6967
+ // A 10 min cap killed 12 runs at exactly 10:00; 20 min fits 89 of 92.
6968
+ claudeTimeoutMs: 20 * 60 * 1000,
6882
6969
  backoff: { initialMs: 30_000, maxMs: 10 * 60_000, factor: 2, jitter: 0.3 },
6883
6970
  };
6884
6971
 
@@ -8865,6 +8952,45 @@ function consumeMissionPings(mission, cwd) {
8865
8952
  return { mission: saved, pings: pending };
8866
8953
  }
8867
8954
 
8955
+ // A one-tick job has no next prompt. Deliver notes that arrived while the
8956
+ // worker ran before checking its work, within the same wall budget. Only a
8957
+ // successful worker response acknowledges a batch; failed delivery can retry.
8958
+ async function deliverLateMissionPings(mission, cwd, firstResult, { run, remainingMs, prompt }) {
8959
+ let result = firstResult;
8960
+ let delivered = 0;
8961
+ while (result.ok) {
8962
+ const latest = resolveMission(mission.id, cwd) || mission;
8963
+ const pending = pendingMissionPings(latest);
8964
+ if (!pending.length || TERMINAL_STATUSES.has(latest.status)) break;
8965
+ if (remainingMs() <= 0) {
8966
+ return { ...result, ok: false, timedOut: true, error: 'unread notes at the wall limit' };
8967
+ }
8968
+ const next = await run([
8969
+ prompt,
8970
+ '',
8971
+ '## Notes received before this job finished',
8972
+ 'This is the same work step. Read and apply these notes before returning the final receipt. Do not start a second slice.',
8973
+ ...pending.map((p) => `- [${p.at}] ${p.from || 'operator'}: ${p.text}`),
8974
+ '',
8975
+ 'Previous work receipt:',
8976
+ result.receipt_text || result.result || '',
8977
+ ].join('\n'));
8978
+ if (!next.ok) return next;
8979
+ // Re-read so notes sent during delivery survive the acknowledgement.
8980
+ const fresh = resolveMission(mission.id, cwd) || latest;
8981
+ const keys = new Set(pending.map((p) => JSON.stringify([p.at, p.from, p.text])));
8982
+ const consumedAt = stampIso();
8983
+ saveMission({
8984
+ ...fresh,
8985
+ pings: (fresh.pings || []).map((p) => p && !p.consumed_at && keys.has(JSON.stringify([p.at, p.from, p.text]))
8986
+ ? { ...p, consumed_at: consumedAt } : p),
8987
+ }, cwd, 'mission_pings_consumed', { count: pending.length });
8988
+ delivered += pending.length;
8989
+ result = { ...next, operator_notes_read: delivered };
8990
+ }
8991
+ return result;
8992
+ }
8993
+
8868
8994
  function readBoundedContextFile(filePath, maxChars = 1600) {
8869
8995
  try {
8870
8996
  if (!fs.existsSync(filePath)) return '';
@@ -9235,6 +9361,22 @@ function tooDeepRunnerResult(depth) {
9235
9361
  };
9236
9362
  }
9237
9363
 
9364
+ // Machine memory and load at both ends of a tick, like a dispatch, so a
9365
+ // killed tick still says whether the machine was short on memory. The
9366
+ // returned end writes once, whichever of close or error comes first.
9367
+ function startTickResource(cwd, engine, mission) {
9368
+ const { recordEngineResource } = require('../lib/engine-resource');
9369
+ const resource = { engine: engine || 'runner', task: mission.task_id || mission.id || '', run_id: crypto.randomUUID() };
9370
+ recordEngineResource(cwd, { phase: 'start', ...resource });
9371
+ const startedAt = process.hrtime.bigint();
9372
+ let ended = false;
9373
+ return ({ signal = null, timedOut = false, error = '' } = {}) => {
9374
+ if (ended) return;
9375
+ ended = true;
9376
+ recordEngineResource(cwd, { phase: 'end', ...resource, signal, timed_out: timedOut, error, duration_ms: Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6) });
9377
+ };
9378
+ }
9379
+
9238
9380
  function spawnGenericRunnerTick(mission, opts) {
9239
9381
  const { sessionId, cwd, signal, timeoutMs, prompt, model, missionLock } = opts;
9240
9382
  const dispatchDepth = missionDispatchDepth();
@@ -9293,6 +9435,7 @@ function spawnGenericRunnerTick(mission, opts) {
9293
9435
  }
9294
9436
 
9295
9437
  const startedAt = Date.now();
9438
+ const endResource = startTickResource(cwd, canonicalEngineName(mission.runner), mission);
9296
9439
  const progressMember = mission.owner || 'mission';
9297
9440
  // A codex run opens its shell script with the run marker as a no-op, so
9298
9441
  // member stop can prove the shell leading this group is this run. Only
@@ -9366,8 +9509,10 @@ function spawnGenericRunnerTick(mission, opts) {
9366
9509
  });
9367
9510
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
9368
9511
 
9369
- proc.on('close', (code) => {
9512
+ proc.on('close', (code, closeSignal) => {
9370
9513
  clearTimeout(timer);
9514
+ // The shell returns 128 + 9 when its engine child was force-killed.
9515
+ endResource({ signal: closeSignal || (code === 137 ? 'SIGKILL' : null), timedOut, error: code === 0 ? '' : stderr.slice(-2000) });
9371
9516
  if (lastMessageFile && eventBuf.trim()) readCodexEvent(eventBuf.trim());
9372
9517
  let finalText = String(stdout || '').trim();
9373
9518
  if (lastMessageFile) {
@@ -9405,6 +9550,7 @@ function spawnGenericRunnerTick(mission, opts) {
9405
9550
 
9406
9551
  proc.on('error', (e) => {
9407
9552
  clearTimeout(timer);
9553
+ endResource({ timedOut, error: e.message });
9408
9554
  cleanupPrompt();
9409
9555
  gitGuard.cleanup();
9410
9556
  if (signal) signal.removeEventListener?.('abort', onAbort);
@@ -9489,6 +9635,7 @@ function spawnClaudeTick(mission, opts) {
9489
9635
  // detached: the runner spawns its own children; killing only the direct
9490
9636
  // child on timeout leaves them holding the session lock, and the next
9491
9637
  // tick's resume fails with "Session ID ... is already in use".
9638
+ const endResource = startTickResource(cwd, 'claude', mission);
9492
9639
  const proc = spawn(resolveClaudeRunnerBin(), args, {
9493
9640
  cwd,
9494
9641
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -9567,8 +9714,9 @@ function spawnClaudeTick(mission, opts) {
9567
9714
 
9568
9715
  proc.stderr.on('data', (c) => { stderr += c.toString(); });
9569
9716
 
9570
- proc.on('close', (code) => {
9717
+ proc.on('close', (code, closeSignal) => {
9571
9718
  clearTimeout(timer);
9719
+ endResource({ signal: closeSignal, timedOut, error: code === 0 ? '' : stderr.slice(-2000) });
9572
9720
  gitGuard.cleanup();
9573
9721
  if (signal) signal.removeEventListener?.('abort', onAbort);
9574
9722
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
@@ -9601,6 +9749,7 @@ function spawnClaudeTick(mission, opts) {
9601
9749
 
9602
9750
  proc.on('error', (e) => {
9603
9751
  clearTimeout(timer);
9752
+ endResource({ timedOut, error: e.message });
9604
9753
  gitGuard.cleanup();
9605
9754
  updateMissionLockOwner(missionLock, missionLock?.driverPid);
9606
9755
  resolve({ ok: false, error: e.message, sessionIds: [], aborted, timedOut, authExpired: false });
@@ -9611,14 +9760,42 @@ function spawnClaudeTick(mission, opts) {
9611
9760
  const ENGINE_VERIFY_TIMEOUT_MS = 5 * 60 * 1000;
9612
9761
  const ENGINE_VERIFY_OUTPUT_LIMIT = 4000;
9613
9762
 
9614
- function buildEngineVerifyPrompt(mission, tickIndex) {
9763
+ // The job's own done-looks-like line, so the reviewer judges the work against
9764
+ // what the job promised instead of the mission headline alone. Only a line the
9765
+ // job wrote down counts: a derived fallback ("it clears review") hands the
9766
+ // reviewer a target that says nothing. Any lookup failure means no line.
9767
+ function missionDoneLooksLike(mission, root = process.cwd()) {
9768
+ try {
9769
+ const task = findOpenTaskMatchingMissionObjective(mission, root, true);
9770
+ if (!task) return '';
9771
+ const explanation = require('../lib/task-explanation').taskExplanation(task);
9772
+ return explanation.sources.done_looks_like === 'explicit' ? explanation.done_looks_like : '';
9773
+ } catch {
9774
+ return '';
9775
+ }
9776
+ }
9777
+
9778
+ function buildEngineVerifyPrompt(mission, tickIndex, doneLooksLike = '', { timeoutMs = ENGINE_VERIFY_TIMEOUT_MS, checkPassed = '' } = {}) {
9779
+ const doneLine = String(doneLooksLike || '').replace(/\s+/g, ' ').trim();
9780
+ const minutes = Math.max(1, Math.round(timeoutMs / 60000));
9615
9781
  return [
9616
9782
  `# Verify Mission Tick ${tickIndex}`,
9617
9783
  ``,
9618
9784
  `This is a bounded verify-only pass for mission: ${mission.objective}`,
9785
+ ...(doneLine ? [
9786
+ `The job says done looks like: ${doneLine}`,
9787
+ `Compare the work against that line. If the work does not show it, the verdict is FAIL even when the checks pass.`,
9788
+ `Split that line into each separate thing it promises and check every one. List each part as met or missing.`,
9789
+ `Work that does only some of the parts is not done. If any part is missing, end with 'VERDICT: FAIL; missing: <that part>'.`,
9790
+ ] : []),
9619
9791
  `Read the diff this tick produced. Do not edit files and do not start another work slice.`,
9620
9792
  `ACTUALLY RUN the changed surface on this computer using real commands and real exit codes.`,
9621
9793
  `Paste the commands and their real output. Do not accept a receipt claim or static inspection as execution proof.`,
9794
+ // Reviews that outran the clock were 11 of 25 turn-downs (10/11 to 10/14).
9795
+ `You have ${minutes} minutes. Leave time to write the verdict; running out of time counts as a fail.`,
9796
+ ...(checkPassed ? [`The mission check \`${checkPassed}\` already passed on this exact code. Do not run it again.`] : []),
9797
+ `Prove the change with the narrowest real run: the changed command, or one test file or one named test.`,
9798
+ `Do not run whole test suites or large test files; on a busy machine they outlast your time.`,
9622
9799
  `End your reply with a final line exactly 'VERDICT: PASS' or 'VERDICT: FAIL'.`,
9623
9800
  ].join('\n');
9624
9801
  }
@@ -9680,7 +9857,10 @@ async function runEngineVerifier(mission, options = {}) {
9680
9857
  cwd,
9681
9858
  signal: options.signal,
9682
9859
  timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9683
- prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1),
9860
+ prompt: buildEngineVerifyPrompt(mission, options.tickIndex || 1, missionDoneLooksLike(mission, cwd), {
9861
+ timeoutMs: ENGINE_VERIFY_TIMEOUT_MS,
9862
+ checkPassed: options.checkPassed || '',
9863
+ }),
9684
9864
  model: resolveMissionTickRunnerModel(verifyMission),
9685
9865
  });
9686
9866
  } catch (error) {
@@ -10139,6 +10319,9 @@ async function executeMissionRunTicksPhase(context) {
10139
10319
  let cycleTickLimit = effectiveMaxTicks;
10140
10320
  while (true) {
10141
10321
  while (ticks.length < cycleTickLimit) {
10322
+ // A campaign that is done, paused, or out of money stops its missions.
10323
+ const campaignStop = mission.campaign_id ? campaignMissions().campaignGate(cwd, mission) : null;
10324
+ if (campaignStop) { pauseReason = campaignStop; break; }
10142
10325
  // Re-read before measuring the wall. Detached full-budget drivers use
10143
10326
  // the mission contract as their wall so process startup drift cannot
10144
10327
  // shorten the promised work window.
@@ -10278,8 +10461,8 @@ async function executeMissionRunTicksPhase(context) {
10278
10461
  }, Math.max(1, Math.floor(remainingWall * 1000)));
10279
10462
  let turn;
10280
10463
  try {
10281
- turn = await runAtris2Turn({
10282
- prompt,
10464
+ const runTurn = (direction) => runAtris2Turn({
10465
+ prompt: direction,
10283
10466
  model: tickRuntimeMission.model || 'atris:fast',
10284
10467
  business: businessId,
10285
10468
  maxTurns: 16,
@@ -10290,6 +10473,16 @@ async function executeMissionRunTicksPhase(context) {
10290
10473
  localEnv: atris2GitGuard.env,
10291
10474
  localPathPrefix: atris2GitGuard.pathPrefix,
10292
10475
  });
10476
+ turn = await runTurn(prompt);
10477
+ turn = await deliverLateMissionPings(mission, cwd, { ...turn, receipt_text: turn.text }, {
10478
+ prompt,
10479
+ remainingMs: () => (maxWallSeconds * 1000) - (Date.now() - startedAt),
10480
+ run: async (direction) => {
10481
+ const next = await runTurn(direction);
10482
+ return { ...next, receipt_text: next.text };
10483
+ },
10484
+ });
10485
+ mission = resolveMission(mission.id, cwd) || mission;
10293
10486
  } finally {
10294
10487
  atris2GitGuard.cleanup();
10295
10488
  clearTimeout(tickTimer);
@@ -10305,6 +10498,7 @@ async function executeMissionRunTicksPhase(context) {
10305
10498
  error: turn.error,
10306
10499
  backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
10307
10500
  receipt_text: String(turn.text || '').slice(0, 4000),
10501
+ operator_notes_read: turn.operator_notes_read,
10308
10502
  };
10309
10503
  tickWorkerRun = { endedMs: Date.now(), usage: {} };
10310
10504
  if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
@@ -10338,14 +10532,14 @@ async function executeMissionRunTicksPhase(context) {
10338
10532
  const restoreTickRunnerProfile = tickEngineId ? applyMissionRunnerProfile(tickEngineId) : () => {};
10339
10533
  let claudeResult;
10340
10534
  let sessionBusyRetried = false;
10341
- const runClaudeSession = () => spawnClaudeTick(tickRuntimeMission, {
10535
+ const runClaudeSession = (direction = workerPrompt) => spawnClaudeTick(tickRuntimeMission, {
10342
10536
  sessionMode, sessionId: useId, cwd, signal: controller.signal,
10343
10537
  missionLock: lock,
10344
10538
  timeoutMs: missionTickTimeoutMs(
10345
10539
  tickRuntimeMission,
10346
10540
  (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
10347
10541
  ),
10348
- prompt: workerPrompt,
10542
+ prompt: direction,
10349
10543
  model: resolveMissionTickRunnerModel(tickRuntimeMission),
10350
10544
  });
10351
10545
  try {
@@ -10362,12 +10556,24 @@ async function executeMissionRunTicksPhase(context) {
10362
10556
  useId = pendingSessionId;
10363
10557
  claudeResult = await runClaudeSession();
10364
10558
  }
10559
+ claudeResult = await deliverLateMissionPings(mission, cwd, claudeResult, {
10560
+ prompt: workerPrompt,
10561
+ remainingMs: () => (maxWallSeconds * 1000) - (Date.now() - startedAt),
10562
+ run: (direction) => {
10563
+ // Claude needs to resume the session it just confirmed. Generic
10564
+ // runners receive the earlier receipt in the delivery prompt.
10565
+ if (claudeResult.sessionIds?.includes(useId)) sessionMode = 'resume';
10566
+ return runClaudeSession(direction);
10567
+ },
10568
+ });
10569
+ mission = resolveMission(mission.id, cwd) || mission;
10365
10570
  } finally {
10366
10571
  restoreTickRunnerProfile();
10367
10572
  }
10368
10573
  tickWorkerRun = { endedMs: Date.now(), usage: (claudeResult && claudeResult.run_usage) || {}, prep: tickPrep, startedAt: workerStartedAt };
10369
10574
  result.claude = {
10370
10575
  ok: claudeResult.ok,
10576
+ error: claudeResult.error,
10371
10577
  reason: claudeResult.reason,
10372
10578
  protected_lane_guard: claudeResult.protected_lane_guard || null,
10373
10579
  brief_id: claudeResult.brief_id || null,
@@ -10384,6 +10590,7 @@ async function executeMissionRunTicksPhase(context) {
10384
10590
  timed_out: claudeResult.timedOut,
10385
10591
  aborted: claudeResult.aborted,
10386
10592
  session_busy_retry: sessionBusyRetried || undefined,
10593
+ operator_notes_read: claudeResult.operator_notes_read,
10387
10594
  };
10388
10595
  if (claudeResult.rate_limit_info) {
10389
10596
  // Claude reports the five-hour window's resetsAt on every turn, even
@@ -10398,7 +10605,7 @@ async function executeMissionRunTicksPhase(context) {
10398
10605
 
10399
10606
  if (!claudeResult.ok) {
10400
10607
  const deadModel = detectUnavailableModel(claudeResult.summary || claudeResult.receipt_text);
10401
- const wallBoundTimeout = remainingWall * 1000 <= MISSION_RUN_DEFAULTS.claudeTimeoutMs;
10608
+ const wallBoundTimeout = missionTickWallBound(tickRuntimeMission, remainingWall * 1000);
10402
10609
  let reason = claudeResult.reason === 'too-deep'
10403
10610
  ? 'too-deep'
10404
10611
  : claudeResult.timedOut
@@ -10488,6 +10695,7 @@ async function executeMissionRunTicksPhase(context) {
10488
10695
  prep: tickWorkerRun.prep || null,
10489
10696
  });
10490
10697
  }
10698
+ if (mission.campaign_id && !cachedStep) campaignMissions().recordCampaignTick(cwd, mission, { ...((tickWorkerRun && tickWorkerRun.usage) || {}), ...(tickEngineId ? { engine: tickEngineId } : {}) });
10491
10699
  let receiptPath = cachedStep ? cachedStep.receipt_path : null;
10492
10700
 
10493
10701
  // Review-lane drain: always-on loops sweep the agent-safe review actions
@@ -10635,7 +10843,7 @@ async function executeMissionRunTicksPhase(context) {
10635
10843
  mission: mission.objective,
10636
10844
  state: mission.status,
10637
10845
  tick_status: result.status,
10638
- reason: result.reason,
10846
+ reason: memberLogTickReason(result),
10639
10847
  layer: result.layer || undefined,
10640
10848
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10641
10849
  summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
@@ -10849,6 +11057,7 @@ async function verifyMissionRunTickPhase(context) {
10849
11057
  signal: controller.signal,
10850
11058
  tickIndex: currentTick.tickIdx,
10851
11059
  refereeEngine: context.refereeEngine,
11060
+ checkPassed: verifierResult?.passed ? currentTick.frozen.verifier : '',
10852
11061
  });
10853
11062
  const afterReview = gitCodeState(cwd);
10854
11063
  const codeMoved = beforeReview.tree !== afterReview.tree
@@ -12493,6 +12702,7 @@ function missionCommand(args) {
12493
12702
  }
12494
12703
 
12495
12704
  module.exports = {
12705
+ listSiblingWorkspaceMissionHints,
12496
12706
  missionProtectedLaneHold,
12497
12707
  missionCommand,
12498
12708
  startMission,
@@ -12545,6 +12755,7 @@ module.exports = {
12545
12755
  missionVerifierCheckedText,
12546
12756
  missionVerifierHighLevelTestText,
12547
12757
  buildEngineVerifyPrompt,
12758
+ missionDoneLooksLike,
12548
12759
  engineVerifierResultFromRun,
12549
12760
  pickCrossEngineVerifier,
12550
12761
  runVerifier,
@@ -12555,6 +12766,8 @@ module.exports = {
12555
12766
  resolveMissionTickRunner,
12556
12767
  resolveMissionTickRunnerModel,
12557
12768
  missionTickTimeoutMs,
12769
+ missionTickWallBound,
12770
+ memberLogTickReason,
12558
12771
  engineFailureHealthStatus,
12559
12772
  recordMissionEngineTickOutcome,
12560
12773
  recordMissionTickRosterRun,