atris 3.82.26 → 3.83.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 (39) hide show
  1. package/AGENTS.md +10 -12
  2. package/README.md +77 -0
  3. package/bin/atris.js +95 -44
  4. package/commands/activate.js +39 -33
  5. package/commands/agent-home.js +403 -0
  6. package/commands/agent-signin.js +214 -0
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/autoland.js +173 -71
  9. package/commands/brain.js +211 -410
  10. package/commands/doctor.js +38 -26
  11. package/commands/init.js +4 -4
  12. package/commands/join.js +159 -0
  13. package/commands/memory.js +2 -2
  14. package/commands/morning.js +132 -0
  15. package/commands/night.js +178 -0
  16. package/commands/recap.js +4 -4
  17. package/commands/task.js +31 -2
  18. package/lib/agent-home.js +289 -0
  19. package/lib/agent-page.js +1257 -0
  20. package/lib/cleanup-summary.js +4 -2
  21. package/lib/first-minute.js +41 -20
  22. package/lib/gm-phone-page.js +8 -2
  23. package/lib/known-commands.js +1 -1
  24. package/lib/member-export.js +31 -0
  25. package/lib/memory-folder.js +122 -24
  26. package/lib/memory-live.js +2 -2
  27. package/lib/night-learned.js +171 -0
  28. package/lib/night-shift.js +123 -0
  29. package/lib/task-core.js +2 -1
  30. package/lib/task-db.js +32 -0
  31. package/package.json +1 -1
  32. package/templates/members/designer-1/MEMBER.md +34 -0
  33. package/templates/members/designer-1/MISSION.md +11 -0
  34. package/templates/members/designer-1/SOUL.md +11 -0
  35. package/templates/members/designer-1/context/why.md +5 -0
  36. package/templates/members/designer-1/goals.json +5 -0
  37. package/templates/members/designer-1/goals.md +3 -0
  38. package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
  39. package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
@@ -736,12 +736,17 @@ async function runTick(root, args) {
736
736
  const json = args.includes('--json');
737
737
  const policy = autoland.readPolicy(root);
738
738
  const receipt = { at: new Date().toISOString(), landed: [], alarms: 0, digest_due: false, digest_sent: false, enabled: Boolean(policy && policy.enabled) };
739
+ // The clock starts before the off and lock checks. Every receipt this
740
+ // command prints carries step_ms, total_ms and ended_at, including the ones
741
+ // where nothing ran (step_ms is empty there).
742
+ const timer = createTickTimer(receipt);
739
743
  if (!policy || policy.enabled !== true) {
744
+ timer.finish();
740
745
  if (json) console.log(JSON.stringify(receipt));
741
746
  else console.log('autoland is off; tick did nothing.');
742
747
  return 0;
743
748
  }
744
- if (args.includes('--dry-run')) return runTickPreview(root, { json, receipt });
749
+ if (args.includes('--dry-run')) return runTickPreview(root, { json, receipt, timer });
745
750
 
746
751
  // One live tick at a time: verify re-runs can take minutes each, and a
747
752
  // second hourly cron firing into the same snapshot double-accepts,
@@ -756,12 +761,13 @@ async function runTick(root, args) {
756
761
  }
757
762
  if (lock && !lock.ok) {
758
763
  receipt.skipped_reason = 'tick_already_running';
764
+ timer.finish();
759
765
  if (json) console.log(JSON.stringify(receipt));
760
766
  else console.log('autoland tick: another tick is still running; skipped.');
761
767
  return 0;
762
768
  }
763
769
  try {
764
- return await runTickBody(root, { json, policy, receipt });
770
+ return await runTickBody(root, { json, policy, receipt, timer });
765
771
  } finally {
766
772
  // Only removes the lock while it is still this tick's.
767
773
  if (lock) lock.release();
@@ -771,7 +777,7 @@ async function runTick(root, args) {
771
777
  // `atris autoland tick --dry-run`: what the hourly pass would close, send
772
778
  // back, and check, read only. No lock, no receipt, no GitHub writes, no job
773
779
  // changes, no fetch.
774
- function runTickPreview(root, { json, receipt }) {
780
+ function runTickPreview(root, { json, receipt, timer }) {
775
781
  receipt.dry_run = true;
776
782
  runMergedWorkClose(root, receipt, { dryRun: true });
777
783
  // The second check's next turn, in the order it would take the jobs.
@@ -786,6 +792,7 @@ function runTickPreview(root, { json, receipt }) {
786
792
  } catch (err) {
787
793
  receipt.second_check_error = String((err && err.message) || err).slice(0, 200);
788
794
  }
795
+ if (timer) timer.finish();
789
796
  if (json) {
790
797
  console.log(JSON.stringify(receipt));
791
798
  return 0;
@@ -1047,24 +1054,105 @@ function acceptCommandArgs(policy) {
1047
1054
  return args;
1048
1055
  }
1049
1056
 
1050
- async function runTickBody(root, { json, policy, receipt, engineValidationDeps = {} }) {
1057
+ // A tick that ran past five minutes left a receipt with no timing at all, so
1058
+ // nobody could say which step ate the time. The timer writes receipt.step_ms
1059
+ // (whole milliseconds per step), receipt.total_ms and receipt.ended_at.
1060
+ // A step that is switched off by policy is absent from step_ms, not zero: a
1061
+ // zero would read as "ran and was instant". A step that returns early or
1062
+ // throws still records its time. The clock is monotonic whole milliseconds:
1063
+ // the steps can never add up to more than the total.
1064
+ const SLOW_STEP_MS = 60 * 1000;
1065
+
1066
+ function monotonicMs() {
1067
+ return Number(process.hrtime.bigint() / 1000000n);
1068
+ }
1051
1069
 
1052
- try {
1053
- receipt.engine_receipt_sweep = sweepEngineAskReceipts(root);
1054
- } catch (err) {
1055
- receipt.engine_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1056
- }
1057
- try {
1058
- const fleetSweep = sweepFleetReceipts(root);
1059
- if (fleetSweep.scanned > 0) receipt.fleet_receipt_sweep = fleetSweep;
1060
- } catch (err) {
1061
- receipt.fleet_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1070
+ function createTickTimer(receipt, now = monotonicMs) {
1071
+ const started = now();
1072
+ const stepMs = {};
1073
+ receipt.step_ms = stepMs;
1074
+ const since = (from) => Math.max(0, Math.round(now() - from));
1075
+ const timed = (name, fn) => {
1076
+ const from = now();
1077
+ // Added, not assigned: a step name used twice keeps both runs.
1078
+ const record = () => { stepMs[name] = (stepMs[name] || 0) + since(from); };
1079
+ let out;
1080
+ try {
1081
+ out = fn();
1082
+ } catch (err) {
1083
+ record();
1084
+ throw err;
1085
+ }
1086
+ if (out && typeof out.then === 'function') {
1087
+ return out.then((value) => { record(); return value; }, (err) => { record(); throw err; });
1088
+ }
1089
+ record();
1090
+ return out;
1091
+ };
1092
+ const finish = () => {
1093
+ receipt.total_ms = since(started);
1094
+ receipt.ended_at = new Date().toISOString();
1095
+ };
1096
+ return { timed, finish };
1097
+ }
1098
+
1099
+ function tookSeconds(ms) {
1100
+ const tenths = Math.round(Math.max(0, Number(ms) || 0) / 100) / 10;
1101
+ return `${tenths < 10 ? tenths.toFixed(1) : Math.round(tenths)}s`;
1102
+ }
1103
+
1104
+ // The longest single step over the line, or null. One step is named so the
1105
+ // line stays one line however many ran long.
1106
+ function slowestStep(receipt, thresholdMs = SLOW_STEP_MS) {
1107
+ let slow = null;
1108
+ for (const [name, ms] of Object.entries((receipt && receipt.step_ms) || {})) {
1109
+ if (Number.isFinite(ms) && ms > thresholdMs && (!slow || ms > slow.ms)) slow = { name, ms };
1062
1110
  }
1111
+ return slow;
1112
+ }
1113
+
1114
+ function tickTimingNote(receipt) {
1115
+ return Number.isFinite(receipt.total_ms) ? `, took ${tookSeconds(receipt.total_ms)}` : '';
1116
+ }
1117
+
1118
+ function slowStepLine(receipt, thresholdMs = SLOW_STEP_MS) {
1119
+ const slow = slowestStep(receipt, thresholdMs);
1120
+ return slow ? `slow step: ${slow.name} took ${tookSeconds(slow.ms)}` : '';
1121
+ }
1122
+
1123
+ // One row of the receipt's green landing list. duration_ms is how long the
1124
+ // full verify took; 0 is a real value, a missing one stays missing.
1125
+ function greenVerdictRow(v) {
1126
+ const row = { branch: v.branch, action: v.action, reason: v.reason || null, detail: v.detail || null, failing: v.verify ? v.verify.failing : undefined };
1127
+ if (v.verify && Number.isFinite(v.verify.duration_ms)) row.duration_ms = v.verify.duration_ms;
1128
+ return row;
1129
+ }
1130
+
1131
+ async function runTickBody(root, { json, policy, receipt, engineValidationDeps = {}, clock, timer }) {
1132
+ // runTick hands in the timer it started before the lock check. The total
1133
+ // covers the wait for the lock too. Called alone, the body starts its own.
1134
+ const { timed, finish } = timer || createTickTimer(receipt, clock);
1135
+
1136
+ timed('engine_receipt_sweep', () => {
1137
+ try {
1138
+ receipt.engine_receipt_sweep = sweepEngineAskReceipts(root);
1139
+ } catch (err) {
1140
+ receipt.engine_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1141
+ }
1142
+ });
1143
+ timed('fleet_receipt_sweep', () => {
1144
+ try {
1145
+ const fleetSweep = sweepFleetReceipts(root);
1146
+ if (fleetSweep.scanned > 0) receipt.fleet_receipt_sweep = fleetSweep;
1147
+ } catch (err) {
1148
+ receipt.fleet_receipt_sweep_error = String((err && err.message) || err).slice(0, 200);
1149
+ }
1150
+ });
1063
1151
 
1064
- runSshAgentSweep(root, receipt);
1065
- runLostRunSweep(root, receipt);
1152
+ timed('ssh_agent_sweep', () => runSshAgentSweep(root, receipt));
1153
+ timed('lost_run_sweep', () => runLostRunSweep(root, receipt));
1066
1154
 
1067
- await runEngineAnswerValidation(root, receipt, engineValidationDeps);
1155
+ await timed('engine_answer_validation', () => runEngineAnswerValidation(root, receipt, engineValidationDeps));
1068
1156
 
1069
1157
  // 1. certify and land in one task process. Keeping both phases together lets
1070
1158
  // the landing gate reuse the live certification verifier result without
@@ -1076,13 +1164,13 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1076
1164
  // policy with accept_all:true actually drains the full certified set.
1077
1165
  // Refresh origin once, before anything is accepted: the landing proof reads
1078
1166
  // origin's target, and a stale ref would call landed work unlanded.
1079
- refreshLandingRefs(root);
1167
+ timed('origin_refresh', () => refreshLandingRefs(root));
1080
1168
  const cliArgs = acceptCommandArgs(policy);
1081
1169
  const pendingReviewRows = readProjection(root)
1082
1170
  .filter((task) => task && task.status === 'review')
1083
1171
  .filter((task) => String(task.review?.approval_status || task.metadata?.approval_status || 'pending') === 'pending')
1084
1172
  .length;
1085
- const accept = runOwnCli(root, cliArgs, acceptSweepTimeoutMs(pendingReviewRows));
1173
+ const accept = timed('accept_certify', () => runOwnCli(root, cliArgs, acceptSweepTimeoutMs(pendingReviewRows)));
1086
1174
  let acceptResults = [];
1087
1175
  try {
1088
1176
  const parsed = JSON.parse(accept.stdout);
@@ -1148,7 +1236,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1148
1236
  // 2a. merged work closes its jobs and clashing work goes back on the list,
1149
1237
  // every hour, whether or not a team loop is running. After the accept step,
1150
1238
  // so work that can be checked and landed the normal way is landed first.
1151
- const mergedClose = runMergedWorkClose(root, receipt);
1239
+ const mergedClose = timed('merged_close', () => runMergedWorkClose(root, receipt));
1152
1240
  if ((mergedClose.closed || []).length + (mergedClose.sent_back || []).length > 0) {
1153
1241
  try { require('./task').refreshKeptTaskList(root); } catch {}
1154
1242
  }
@@ -1157,7 +1245,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1157
1245
  // sentence per piece (the day-one PM sentence written at finish time),
1158
1246
  // capped at three with the rest counted. Off with live_updates: false.
1159
1247
  const tasksForLive = readProjection(root);
1160
- if (receipt.landed.length > 0 && policy.imessage_to && policy.live_updates !== false) {
1248
+ if (receipt.landed.length > 0 && policy.imessage_to && policy.live_updates !== false) timed('live_update', () => {
1161
1249
  const text = autoland.composeLiveUpdate({
1162
1250
  landedRefs: receipt.landed,
1163
1251
  tasks: tasksForLive,
@@ -1171,7 +1259,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1171
1259
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
1172
1260
  receipt.live_update_sent = sent.ok;
1173
1261
  }
1174
- }
1262
+ });
1175
1263
 
1176
1264
  // 3. alarm on anything waiting on a human past the line
1177
1265
  const state = autoland.readState(root);
@@ -1180,14 +1268,14 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1180
1268
  const protectedWaiting = protectedReviewWaiting(root, policy, waiting);
1181
1269
  const alarmHours = Number(policy.alarm_hours) || autoland.DEFAULT_ALARM_HOURS;
1182
1270
  const due = autoland.dueForAlarm(waiting, state, { alarmHours });
1183
- if (due.length > 0 && policy.imessage_to) {
1271
+ if (due.length > 0 && policy.imessage_to) timed('alarm', () => {
1184
1272
  const text = autoland.composeAlarm({ waiting: due, project: projectName(root), alarmHours });
1185
1273
  const sent = autoland.sendImessage(root, policy.imessage_to, text);
1186
1274
  if (sent.ok) {
1187
1275
  autoland.markAlerted(state, due);
1188
1276
  receipt.alarms = due.length;
1189
1277
  }
1190
- }
1278
+ });
1191
1279
 
1192
1280
  // 3b. janitor, every tick: a mission left paused past 48h and a worktree
1193
1281
  // already merged into base are the same rot pattern as an unlanded branch,
@@ -1200,7 +1288,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1200
1288
  receipt.missions_stopped = 0;
1201
1289
  receipt.missions_held = 0;
1202
1290
  receipt.worktrees_reaped = 0;
1203
- if (policy.janitor !== false) {
1291
+ if (policy.janitor !== false) timed('janitor', () => {
1204
1292
  let stoppedMissions = [];
1205
1293
  let heldMissions = [];
1206
1294
  try {
@@ -1227,13 +1315,13 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1227
1315
  receipt.janitor_worktree_error = String((err && err.message) || err).slice(0, 200);
1228
1316
  }
1229
1317
  recordJanitorState(state, { stopped: stoppedMissions, held: heldMissions, worktrees: receipt.worktrees_reaped });
1230
- }
1318
+ });
1231
1319
 
1232
1320
  // 3b2. green landing, every tick: one stale branch that merges clean and
1233
1321
  // passes the full verify in a scratch checkout lands on base and is pushed.
1234
1322
  // A red branch opens a close flag the same hour instead of waiting for the
1235
1323
  // TTL reap to salvage it. Off by policy: green_landing:false.
1236
- if (policy.green_landing !== false) {
1324
+ if (policy.green_landing !== false) timed('green_landing', () => {
1237
1325
  try {
1238
1326
  const { landStaleGreenBranches, summaryLine } = require('../lib/land-green');
1239
1327
  const green = landStaleGreenBranches(root, {
@@ -1244,35 +1332,37 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1244
1332
  candidates: green.candidates.length,
1245
1333
  landed: green.landed,
1246
1334
  red: green.red,
1247
- verdicts: green.verdicts.map((v) => ({ branch: v.branch, action: v.action, reason: v.reason || null, detail: v.detail || null, failing: v.verify ? v.verify.failing : undefined })),
1335
+ verdicts: green.verdicts.map(greenVerdictRow),
1248
1336
  summary: summaryLine(green),
1249
1337
  };
1250
1338
  state.green_landing = { at: green.at, ...receipt.green_landing };
1251
1339
  } catch (err) {
1252
1340
  receipt.green_landing_error = String((err && err.message) || err).slice(0, 200);
1253
1341
  }
1254
- }
1342
+ });
1255
1343
 
1256
1344
  // 3c. landing sweep, every tick: merged branch residue is cleared,
1257
1345
  // TTL-expired work is salvaged before deletion, and 48h-stale active work
1258
1346
  // is carried into the digest instead of silently aging forever.
1259
- try {
1260
- const landingSweep = sweepLanding(root, { refresh: false });
1261
- receipt.reaped = landingSweep.reaped;
1262
- receipt.landing_sweep = {
1263
- before: landingSweep.before,
1264
- after: landingSweep.after,
1265
- stale: landingSweep.stale.length,
1266
- human: landingSweep.human.length,
1267
- };
1268
- if (landingSweep.reaped.bundleError) {
1269
- receipt.reap_error = `backup failed, unlanded work kept in place: ${landingSweep.reaped.bundleError}`;
1347
+ timed('landing_sweep', () => {
1348
+ try {
1349
+ const landingSweep = sweepLanding(root, { refresh: false });
1350
+ receipt.reaped = landingSweep.reaped;
1351
+ receipt.landing_sweep = {
1352
+ before: landingSweep.before,
1353
+ after: landingSweep.after,
1354
+ stale: landingSweep.stale.length,
1355
+ human: landingSweep.human.length,
1356
+ };
1357
+ if (landingSweep.reaped.bundleError) {
1358
+ receipt.reap_error = `backup failed, unlanded work kept in place: ${landingSweep.reaped.bundleError}`;
1359
+ }
1360
+ recordLandingSweepState(state, landingSweep);
1361
+ } catch (err) {
1362
+ receipt.reap_error = String((err && err.message) || err).slice(0, 200);
1363
+ recordLandingSweepState(state, null, { error: receipt.reap_error });
1270
1364
  }
1271
- recordLandingSweepState(state, landingSweep);
1272
- } catch (err) {
1273
- receipt.reap_error = String((err && err.message) || err).slice(0, 200);
1274
- recordLandingSweepState(state, null, { error: receipt.reap_error });
1275
- }
1365
+ });
1276
1366
 
1277
1367
  // 3c2. idle work resolves itself, every tick. Finished work that sat still
1278
1368
  // past the grace period (policy.idle_close_days, default 7) and can never
@@ -1281,7 +1371,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1281
1371
  // nights and trips piled up work nobody would ever land. Protected lanes
1282
1372
  // (money, deploys, security, customer, outward) are never closed by idle
1283
1373
  // time. Off switch: policy.idle_sweep === false or policy.janitor === false.
1284
- if (policy.janitor !== false && policy.idle_sweep !== false) {
1374
+ if (policy.janitor !== false && policy.idle_sweep !== false) timed('idle_sweep', () => {
1285
1375
  try {
1286
1376
  const liveReasons = new Map(acceptResults
1287
1377
  .filter((r) => r && r.ref && r.reason && r.action !== 'accepted')
@@ -1302,7 +1392,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1302
1392
  } catch (err) {
1303
1393
  receipt.idle_sweep_error = String((err && err.message) || err).slice(0, 200);
1304
1394
  }
1305
- }
1395
+ });
1306
1396
  try {
1307
1397
  const { appendOvernightNote, tickSummaryLine } = require('../lib/cleanup-summary');
1308
1398
  const line = tickSummaryLine({
@@ -1321,17 +1411,19 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1321
1411
 
1322
1412
  // 3d. wish sweep: queued wishes are allowed to wake a mission without a
1323
1413
  // foreground `atris wish` session, but they must never endanger landing.
1324
- try {
1325
- const wishDispatch = require('./wish').sweepWishes(root);
1326
- receipt.wish_dispatch = wishDispatch;
1327
- } catch (err) {
1328
- receipt.wish_dispatch_error = String((err && err.message) || err).slice(0, 200);
1329
- }
1414
+ timed('wish_dispatch', () => {
1415
+ try {
1416
+ const wishDispatch = require('./wish').sweepWishes(root);
1417
+ receipt.wish_dispatch = wishDispatch;
1418
+ } catch (err) {
1419
+ receipt.wish_dispatch_error = String((err && err.message) || err).slice(0, 200);
1420
+ }
1421
+ });
1330
1422
 
1331
1423
  // 3e. map fold, once an hour: map notes member changes saved as their own
1332
1424
  // files under atris/refs/map-notes.d/ are folded into the shared map notes
1333
1425
  // on main and removed, in one pushed commit. Off by policy: map_fold:false.
1334
- if (policy.map_fold !== false) {
1426
+ if (policy.map_fold !== false) timed('map_fold', () => {
1335
1427
  try {
1336
1428
  const { foldDue, foldMapNotesOnMain } = require('../lib/member-map-fold');
1337
1429
  if (foldDue(state.last_map_fold_at)) {
@@ -1342,12 +1434,12 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1342
1434
  } catch (err) {
1343
1435
  receipt.map_fold_error = String((err && err.message) || err).slice(0, 200);
1344
1436
  }
1345
- }
1437
+ });
1346
1438
 
1347
1439
  // 4. daily digest at the configured hour
1348
1440
  const today = localDay();
1349
1441
  const digestHour = Number(policy.digest_hour ?? autoland.DEFAULT_DIGEST_HOUR);
1350
- if (new Date().getHours() === digestHour && !digestSentToday(state)) {
1442
+ if (new Date().getHours() === digestHour && !digestSentToday(state)) timed('daily_digest', () => {
1351
1443
  receipt.digest_due = true;
1352
1444
  let waitingWishes = [];
1353
1445
  try {
@@ -1385,29 +1477,31 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1385
1477
  } catch {
1386
1478
  receipt.receipts_pruned = null;
1387
1479
  }
1388
- }
1480
+ });
1389
1481
 
1390
1482
  // 5b. daily keep/revert experiment, self-gated inside experiments daily;
1391
1483
  // hourly ticks are harmless no-ops after the first run each day.
1392
1484
  if (policy.daily_experiment !== false) {
1393
- const daily = runOwnCli(root, ['experiments', 'daily', '--json']);
1394
- receipt.daily_experiment = {
1395
- status: daily.status,
1396
- stdout: daily.stdout.trim(),
1397
- stderr: daily.stderr.slice(0, 200) || null,
1398
- };
1399
- try {
1400
- receipt.daily_experiment.result = JSON.parse(daily.stdout.trim());
1401
- } catch {
1402
- receipt.daily_experiment.result = null;
1403
- }
1485
+ timed('daily_experiment', () => {
1486
+ const daily = runOwnCli(root, ['experiments', 'daily', '--json']);
1487
+ receipt.daily_experiment = {
1488
+ status: daily.status,
1489
+ stdout: daily.stdout.trim(),
1490
+ stderr: daily.stderr.slice(0, 200) || null,
1491
+ };
1492
+ try {
1493
+ receipt.daily_experiment.result = JSON.parse(daily.stdout.trim());
1494
+ } catch {
1495
+ receipt.daily_experiment.result = null;
1496
+ }
1497
+ });
1404
1498
  } else {
1405
1499
  receipt.daily_experiment_skipped = true;
1406
1500
  }
1407
1501
 
1408
1502
  // 6. once a day, do slower mission cleanup. Landing cleanup runs hourly
1409
1503
  // above, outside this date gate, so limbo cannot silently accumulate.
1410
- if (state.last_mission_cleanup_date !== today) {
1504
+ if (state.last_mission_cleanup_date !== today) timed('mission_cleanup', () => {
1411
1505
  // Missions rot the same way branches do: paused/planning/ready and
1412
1506
  // untouched for a week means abandoned. Age them out under the same
1413
1507
  // daily gate so `mission list` shows work, not archaeology.
@@ -1435,9 +1529,11 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1435
1529
  receipt.mission_verify_errors = [{ error: String((err && err.message) || err).slice(0, 200) }];
1436
1530
  }
1437
1531
  state.last_mission_cleanup_date = today;
1438
- }
1532
+ });
1439
1533
  autoland.writeState(root, state);
1440
1534
 
1535
+ // Stamped last. The total covers every step and the state write.
1536
+ finish();
1441
1537
  try {
1442
1538
  persistTickReceipt(root, receipt);
1443
1539
  } catch (err) {
@@ -1458,10 +1554,12 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1458
1554
  // Always print the standing backlog, and name work that cleared every
1459
1555
  // gate and still did not land. That is the shape of a stuck loop.
1460
1556
  const stuckNote = stuckBacklogNote(receipt);
1461
- const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}${mergedCloseNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
1557
+ const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}${mergedCloseNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${tickTimingNote(receipt)}${receiptNote}`;
1462
1558
  const validationLine = engineAnswerValidationLine(receipt);
1463
1559
  if (validationLine) console.log(validationLine);
1464
1560
  console.log(gateForHuman(summary).text);
1561
+ const slowLine = slowStepLine(receipt);
1562
+ if (slowLine) console.log(slowLine);
1465
1563
  for (const fulfilled of receipt.wish_dispatch?.fulfilled_results || []) {
1466
1564
  if (fulfilled.review_ask) console.log(fulfilled.review_ask);
1467
1565
  }
@@ -1535,6 +1633,10 @@ module.exports = {
1535
1633
  hasAgentJargon,
1536
1634
  runEngineAnswerValidation,
1537
1635
  runTickBody,
1636
+ createTickTimer,
1637
+ greenVerdictRow,
1638
+ slowStepLine,
1639
+ tickTimingNote,
1538
1640
  stuckBacklogNote,
1539
1641
  waitingReasonRows,
1540
1642
  acceptCommandArgs,