@bongos/core 1.21.5 → 1.21.6

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/.bongos-core.json +138 -68
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  4. package/docs/api/openapi.json +2 -1
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +156 -144
  7. package/docs/copy-registry.json +286 -158
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  10. package/docs/onboarding/diagrams/assertions.json +1 -1
  11. package/docs/page-inventory.json +31 -4
  12. package/docs/page-readings.json +121 -94
  13. package/modules/autonomy/config-idle.js +192 -0
  14. package/modules/autonomy/routes/autonomy.js +22 -0
  15. package/modules/autonomy/runner-health.js +22 -1
  16. package/modules/hall-ui/public/city-draw.js +438 -0
  17. package/modules/hall-ui/public/city.css +110 -0
  18. package/modules/hall-ui/public/city.html +71 -0
  19. package/modules/hall-ui/public/city.js +177 -0
  20. package/modules/hall-ui/public/city.states.json +30 -0
  21. package/modules/hall-ui/public/gate.js +11 -4
  22. package/modules/hall-ui/public/profile.css +4 -0
  23. package/modules/hall-ui/public/profile.js +25 -1
  24. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  25. package/modules/hall-ui/public/settings.css +26 -0
  26. package/modules/hall-ui/public/settings.html +8 -0
  27. package/modules/hall-ui/public/settings.js +43 -3
  28. package/modules/platform-identity/craft-rollup.js +72 -0
  29. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  30. package/modules/platform-identity/platform-identity.js +12 -6
  31. package/modules/platform-identity/routes/sso.js +4 -0
  32. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  33. package/modules/provisioning/core-upgrade.js +34 -7
  34. package/modules/provisioning/module.json +2 -1
  35. package/modules/provisioning/seams.js +2 -0
  36. package/modules/public-landing/public/projects.states.json +1 -0
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +58 -0
  40. package/scripts/gds/autobongos-grade-cap.js +187 -0
  41. package/scripts/gds/autobongos-loop.js +4 -0
  42. package/scripts/gds/autobongos-run.js +154 -13
  43. package/scripts/gds/autobongos-verify.js +211 -14
  44. package/scripts/gds/provision-core-upgrade.js +14 -2
  45. package/scripts/gds/provision-repo.js +77 -13
  46. package/scripts/gds/ship-flow.js +5 -0
  47. package/scripts/gds/ship-preflight-steps.js +2 -1
  48. package/scripts/gds/ship.js +10 -0
  49. package/scripts/gds/update-sweep.js +24 -8
  50. package/scripts/gds/upgrade-outcome.js +1 -0
  51. package/src/bongos/auth-admission.js +95 -13
  52. package/src/bongos/db-kernel.js +1 -1
  53. package/src/bongos/routes/core-update.js +27 -2
  54. package/src/bongos/serve-internal.js +14 -0
  55. package/src/bongos/software-update.js +3 -2
  56. package/src/module-api.js +1 -1
  57. package/tests/activity_rollup_order_db.mjs +3 -1
  58. package/tests/activity_snapshots_db.mjs +2 -0
  59. package/tests/autobongos_grade_cap.mjs +125 -0
  60. package/tests/autobongos_loop.mjs +230 -1
  61. package/tests/autobongos_verify.mjs +239 -3
  62. package/tests/autonomy_config_idle.mjs +272 -0
  63. package/tests/core_update_banner.mjs +29 -0
  64. package/tests/core_upgrade_door.mjs +31 -1
  65. package/tests/core_upgrade_runner.mjs +20 -0
  66. package/tests/hall_audit.mjs +10 -1
  67. package/tests/hall_city.mjs +369 -0
  68. package/tests/hall_page_gate_map.mjs +5 -0
  69. package/tests/hub_craft_rollup.mjs +350 -0
  70. package/tests/profile_rollup_consent.mjs +2 -0
  71. package/tests/profile_route.mjs +16 -2
  72. package/tests/provision.mjs +78 -0
  73. package/tests/settings_main_role.mjs +190 -0
  74. package/tests/software_update.mjs +56 -1
  75. package/tests/update_subscription_engine.mjs +55 -3
  76. package/tests/wizard_physics_more.mjs +19 -0
@@ -42,7 +42,8 @@ const gauge = require('./autonomy-gauge.js');
42
42
  const {
43
43
  buildWorkerArgs, buildWorkerPrompt, classifyRun, usageLimitHit, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
44
44
  } = require('./autobongos-loop.js');
45
- const { verifyShip, failureReason } = require('./autobongos-verify.js');
45
+ const { verifyShip, failureReason, recheckLands, pendingLandIds } = require('./autobongos-verify.js');
46
+ const gradeCap = require('./autobongos-grade-cap.js');
46
47
  const fence = require('../../modules/autonomy/fence.js');
47
48
  // The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
48
49
  // sits outside the kernel boundary, so this direct core require is allowed here
@@ -51,6 +52,7 @@ const fence = require('../../modules/autonomy/fence.js');
51
52
  const { matchProtected } = require('../../src/bongos/permission-path-check');
52
53
  const { resolveEnv, envPrefix, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
53
54
  const cadence = require('../../modules/autonomy/cadence.js');
55
+ const configIdle = require('../../modules/autonomy/config-idle.js');
54
56
 
55
57
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
56
58
 
@@ -106,6 +108,58 @@ function record(entry) {
106
108
  return row;
107
109
  }
108
110
 
111
+ // readRunLogRows — the run log back as rows, newest last. Only the TAIL is read
112
+ // (a log that runs for weeks must not be slurped whole every loop), and a line
113
+ // that will not parse — the half-written last line of a kill -9 — is skipped.
114
+ function readRunLogRows(p = logPath(), maxBytes = 4 * 1024 * 1024) {
115
+ let text = '';
116
+ try {
117
+ const size = fs.statSync(p).size;
118
+ const fd = fs.openSync(p, 'r');
119
+ try {
120
+ const len = Math.min(size, maxBytes);
121
+ const buf = Buffer.alloc(len);
122
+ fs.readSync(fd, buf, 0, len, size - len);
123
+ text = buf.toString('utf8');
124
+ } finally { fs.closeSync(fd); }
125
+ } catch (_) { return []; }
126
+ const rows = [];
127
+ for (const line of text.split('\n')) {
128
+ if (!line.trim()) continue;
129
+ try { rows.push(JSON.parse(line)); } catch (_) { /* partial line */ }
130
+ }
131
+ return rows;
132
+ }
133
+
134
+ // recheckPendingLands — once per loop (task 1004538). Which tasks are waiting on
135
+ // a land comes from the run log on disk and when they shipped comes from the
136
+ // ledger, so a restart loses neither. Open `shipped_no_artifact` blockers are read
137
+ // from the database and resolved once main carries the commit. Every outcome is a
138
+ // row, so the next pass reads it back.
139
+ //
140
+ // The blocker sweep runs at most once per LAND_SWEEP_MS per process (and at once
141
+ // after a restart): a false alarm closing within the hour is plenty, and the
142
+ // open-blocker list need not be read on every loop. Pending lands are re-probed
143
+ // every loop — and cost nothing when there are none.
144
+ const LAND_SWEEP_MS = 60 * 60 * 1000;
145
+ async function recheckPendingLands(deps = {}) {
146
+ const log = deps.record || record;
147
+ const st = deps.state || RUNNER_STATE;
148
+ const now = deps.now ? deps.now() : Date.now();
149
+ const sweepBlockers = !st.landSweepAt || now - st.landSweepAt >= LAND_SWEEP_MS;
150
+ const ids = pendingLandIds(readRunLogRows());
151
+ if (!ids.length && !sweepBlockers) return { rechecked: [], resolved: [], errors: [] };
152
+ const out = await recheckLands({ pendingIds: ids, sweepBlockers }, { ...(deps.api ? { api: deps.api } : {}), ...(deps.landDeps || {}) });
153
+ if (sweepBlockers) st.landSweepAt = now;
154
+ for (const r of out.rechecked) log({ event: 'land_recheck', ...r });
155
+ for (const r of out.resolved) {
156
+ log({ event: 'land_blocker_resolved', task_id: r.task_id, blocker_id: r.blocker_id, sha: r.sha,
157
+ reason: `main now carries ${r.sha} naming task ${r.task_id} — the no-artifact blocker was a slow land, resolved` });
158
+ }
159
+ if (out.errors.length) log({ event: 'land_recheck_failed', errors: out.errors.slice(0, 10) });
160
+ return out;
161
+ }
162
+
109
163
  // pickedFrom — the row fields that say where a pick came from (task 1004453).
110
164
  // `priority` is true only when the fence named a priority goal AND this pick came
111
165
  // from it, so "the priority goal ran dry and the runner moved on" reads as
@@ -246,9 +300,41 @@ function resolveWorkerBin(env = process.env, platform = process.platform) {
246
300
  return { bin: 'claude', shell: true };
247
301
  }
248
302
 
303
+ // gradeLedgerPath — the run's LOCAL ledger (task 1004536): the file ship.js reads
304
+ // to refuse a third grade early. A fresh file per run, under the instance config
305
+ // dir, never inside the task's tree. It is the courtesy half of the cap — see the
306
+ // header of autobongos-grade-cap.js; the boundary is the server count below.
307
+ const GRADE_CAP_WATCH_MS = 30_000;
308
+ function gradeLedgerPath(taskId) {
309
+ const id = Number(taskId);
310
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error(`gradeLedgerPath: task id must be a positive integer, got ${taskId}`);
311
+ const name = path.join('autobongos-grades', `${id}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}.jsonl`);
312
+ try { return require('../../src/instance-config.js').configPath(name); }
313
+ catch (_) { return path.join(os.tmpdir(), name); }
314
+ }
315
+
316
+ // gradeCapReader — how the supervisor counts this run's graded rounds.
317
+ //
318
+ // The SERVER's grade_attempts is the count that matters: the worker has no write
319
+ // path to it, so clearing an env var or deleting a file cannot reset it. The
320
+ // baseline is the newest attempt recorded BEFORE the worker starts; only rounds
321
+ // after it belong to this run. When the server cannot be read, the local ledger
322
+ // is the fallback — and either source showing the cap exhausted is enough.
323
+ async function gradeCapReader({ api, taskId, ledger }) {
324
+ const before = await gradeCap.readServerAttempts(api, taskId);
325
+ const afterMs = before ? gradeCap.newestAttemptAt(before) : null;
326
+ return async function read() {
327
+ const local = gradeCap.gradeCapState(gradeCap.readLedger(ledger), taskId);
328
+ const attempts = before ? await gradeCap.readServerAttempts(api, taskId) : null;
329
+ if (!attempts) return { ...local, source: 'local' };
330
+ const server = gradeCap.gradeCapState(gradeCap.roundsFromAttempts(attempts, taskId, afterMs), taskId);
331
+ return server.exhausted || !local.exhausted ? { ...server, source: 'server' } : { ...local, source: 'local' };
332
+ };
333
+ }
334
+
249
335
  // spawnWorker — one headless session, bounded by WALL CLOCK, in the task's own
250
336
  // working tree.
251
- async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
337
+ async function spawnWorker({ task, model, timeoutMs, cwd, gradeLedger = null, capCheck = null, deps = {} }) {
252
338
  const spawnFn = deps.spawn || spawn;
253
339
  const sessionId = deps.sessionId || crypto.randomUUID(); // pinned, so the session can be resumed
254
340
  const { bin, shell: workerShell } = deps.resolveBin ? deps.resolveBin() : resolveWorkerBin();
@@ -267,22 +353,42 @@ async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
267
353
  stdio: ['pipe', 'pipe', 'pipe'],
268
354
  shell: workerShell,
269
355
  windowsHide: true,
270
- env: workerEnv(),
356
+ // The run's local grade ledger (task 1004536): ship.js appends each graded
357
+ // round to it and refuses a third early. The worker can see this variable,
358
+ // so it is the courtesy half only — capCheck below is the boundary.
359
+ env: workerEnv(process.env, gradeLedger ? { [gradeCap.LEDGER_ENV]: gradeLedger } : {}),
271
360
  });
272
361
  } catch (e) { return resolve({ spawnError: e.message }); }
273
362
 
274
- let stdout = ''; let stderr = ''; let timedOut = false; let truncated = false;
363
+ let stdout = ''; let stderr = ''; let timedOut = false; let truncated = false; let gradeCapped = false;
275
364
  const cap = (cur, d) => {
276
365
  if (cur.length >= MAX_WORKER_OUTPUT) { truncated = true; return cur; }
277
366
  return cur + String(d).slice(0, MAX_WORKER_OUTPUT - cur.length);
278
367
  };
279
368
  const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch (_) {} }, timeoutMs);
369
+ // task 1004536: once the second graded FAIL is recorded the run is over — the
370
+ // task goes to the owner. capCheck reads the SERVER's grade_attempts (see
371
+ // gradeCapReader), so a worker that cleared the ledger env or deleted the file
372
+ // is still stopped. One read at a time: a slow API must not stack polls.
373
+ let capBusy = false;
374
+ const capWatch = capCheck ? setInterval(async () => {
375
+ if (capBusy || gradeCapped) return;
376
+ capBusy = true;
377
+ try {
378
+ if ((await capCheck()).exhausted) {
379
+ gradeCapped = true; clearInterval(capWatch);
380
+ try { child.kill('SIGKILL'); } catch (_) {}
381
+ }
382
+ } catch (_) { /* an unreadable count is retried next tick; the post-run read still decides */ }
383
+ capBusy = false;
384
+ }, deps.capWatchMs || GRADE_CAP_WATCH_MS) : null;
385
+ const stopWatch = () => { if (capWatch) clearInterval(capWatch); };
280
386
  child.stdout.on('data', (d) => { stdout = cap(stdout, d); });
281
387
  child.stderr.on('data', (d) => { stderr = cap(stderr, d); });
282
- child.on('error', (e) => { clearTimeout(timer); resolve({ spawnError: e.message, sessionId }); });
388
+ child.on('error', (e) => { clearTimeout(timer); stopWatch(); resolve({ spawnError: e.message, sessionId }); });
283
389
  child.on('close', (code) => {
284
- clearTimeout(timer);
285
- resolve({ envelope: stdout, exitCode: code, timedOut, truncated, sessionId, stderr: stderr.slice(-2000) });
390
+ clearTimeout(timer); stopWatch();
391
+ resolve({ envelope: stdout, exitCode: code, timedOut, truncated, gradeCapped, sessionId, stderr: stderr.slice(-2000) });
286
392
  });
287
393
  child.stdin.end(prompt);
288
394
  });
@@ -368,7 +474,7 @@ const MAX_CLAIM_ATTEMPTS = 5;
368
474
  const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
369
475
 
370
476
  function newRunnerState() {
371
- return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null };
477
+ return { setAside: new Map(), queueGate: null, limitUntil: null, diskHead: null, mainHead: null, landSweepAt: null };
372
478
  }
373
479
  const RUNNER_STATE = newRunnerState();
374
480
 
@@ -543,8 +649,24 @@ async function iteration(opts, deps = {}) {
543
649
 
544
650
  // 5. Work, in that tree.
545
651
  const started = Date.now();
546
- const res = await spawnWork({ task, model: opts.model, timeoutMs: opts.timeoutMs, cwd: wtPath, deps });
547
- const verdict = classifyRun(res);
652
+ const gradeLedger = deps.gradeLedgerPath ? deps.gradeLedgerPath(task.id) : gradeLedgerPath(task.id);
653
+ // Same client rule as verifyShip below: an injected api is used as given.
654
+ let capApi = deps.api || null;
655
+ if (!capApi) { try { capApi = await (require('./cli-lib').cliClient)(); } catch (_) { capApi = null; } }
656
+ const capCheck = await gradeCapReader({ api: capApi, taskId: task.id, ledger: gradeLedger });
657
+ const res = await spawnWork({ task, model: opts.model, timeoutMs: opts.timeoutMs, cwd: wtPath, gradeLedger, capCheck, deps });
658
+ let verdict = classifyRun(res);
659
+ // 5b. THE GRADE CAP (task 1004536). The owner's rule — a task that fails the
660
+ // grade twice goes to the owner — read from the run's ledger, not from the
661
+ // worker's verdict: the worker was told this in its prompt and kept re-grading.
662
+ const grades = await capCheck();
663
+ try { fs.rmSync(gradeLedger, { force: true }); } catch (_) {} // its counts travel in the run-log row
664
+ if (grades && grades.exhausted) {
665
+ verdict = {
666
+ ...verdict, outcome: 'needs_human', releaseClaim: true,
667
+ reason: `the grade failed ${grades.fails} times in this run — handed to the owner instead of re-graded again (task 1004536)`,
668
+ };
669
+ }
548
670
  // Cut off by the usage limit? Recorded here so the NEXT iteration sleeps to the
549
671
  // reset instead of spawning a worker that will be cut off the same way.
550
672
  const limit = verdict.outcome === 'claims_shipped' ? null : usageLimitHit({ envelope: res.envelope, stderr: res.stderr });
@@ -580,6 +702,7 @@ async function iteration(opts, deps = {}) {
580
702
  outcome: verdict.outcome, reason: verdict.reason,
581
703
  session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
582
704
  cost_usd: verdict.costUsd ?? null,
705
+ ...(grades && (grades.graded || grades.outages) ? { grade_rounds: grades.graded, grade_outages: grades.outages, grade_cap_reached: grades.exhausted, grade_count_source: grades.source } : {}),
583
706
  output_truncated: !!res.truncated,
584
707
  ...(limit ? { usage_limit: { reset_at: limit.resetAt } } : {}),
585
708
  undetermined_decisions: verdict.verdict ? verdict.verdict.undetermined_decisions : null,
@@ -1054,7 +1177,14 @@ async function forever(opts, deps = {}) {
1054
1177
  // down the thing it watches is strictly worse than a gap in the log, and the gap
1055
1178
  // reports itself anyway: the hall reads the AGE of the last check-in, so a
1056
1179
  // failed beat shows up as silence, which is the signal.
1057
- const beat = async (fields) => {
1180
+ // How long this runner has been idle on a setting the owner can fix (task
1181
+ // 1004539). Past that setting's grace, every beat that is not about a task says
1182
+ // `config_idle:<code>` instead of "waiting", so the hall shows the fix rather
1183
+ // than "alive" for ten hours. Any row that is not that refusal ends the stretch.
1184
+ let idle = null;
1185
+ const clock = () => (deps.now ? deps.now() : Date.now());
1186
+ const beat = async (rawFields) => {
1187
+ const fields = configIdle.markBeat(rawFields, idle, clock());
1058
1188
  // The runner state is passed, not defaulted: the file names `disk_head` from
1059
1189
  // it (task 1004406), and an injected state must reach the beat that reports it.
1060
1190
  try { beatFile(Date.now(), deps.state || RUNNER_STATE); } catch (_) { /* a runner that cannot write its file still runs */ }
@@ -1081,6 +1211,15 @@ async function forever(opts, deps = {}) {
1081
1211
  // Every loop, so a crash leaves a heartbeat that goes stale on its own — the
1082
1212
  // runner never has to notice it is dying for its death to become visible.
1083
1213
  await beat({ mode: state.mode, consecutive_failures: state.consecutiveFailures, last_event: 'loop' });
1214
+ // Slow lands (task 1004538): re-probe shipped tasks still waiting on main, and
1215
+ // close no-artifact blockers whose commit has since landed. Armed only by
1216
+ // main() (or an injected stub), like refreshInPlace, so no test harness that
1217
+ // predates it can fall through to the network. It can never stop the loop.
1218
+ if (opts.recheckLands || deps.recheckLands) {
1219
+ try { await (deps.recheckLands || recheckPendingLands)(deps); } catch (e) {
1220
+ log({ event: 'land_recheck_failed', errors: [e && e.message ? e.message : String(e)] });
1221
+ }
1222
+ }
1084
1223
  // In `probing` the runner takes exactly ONE task and only after its wait, so
1085
1224
  // a broken window costs a handful of cheap requests rather than a night of
1086
1225
  // failing spawns. `maxTasks` still bounds a full-speed burst.
@@ -1090,6 +1229,7 @@ async function forever(opts, deps = {}) {
1090
1229
  for (let i = 0; i < budget; i++) {
1091
1230
  row = await iterate(opts, deps);
1092
1231
  emit(row);
1232
+ idle = configIdle.trackIdle(idle, row, clock());
1093
1233
  // Carry the task id so the hall can tell "quiet because it is building
1094
1234
  // task 1234" from "quiet because it is dead". A worker may hold the loop for
1095
1235
  // ninety minutes, and without this every honest hour of work looks like a
@@ -1161,7 +1301,7 @@ async function main(argv) {
1161
1301
  // it lived on the machine being fenced; the database owns that now, and an empty
1162
1302
  // `--goal` means "every allowlisted goal" rather than "anything claimable". The
1163
1303
  // refusal did not disappear — it moved somewhere the runner host cannot edit.
1164
- if (opts.forever) return forever({ ...opts, refreshInPlace: true });
1304
+ if (opts.forever) return forever({ ...opts, refreshInPlace: true, recheckLands: true });
1165
1305
 
1166
1306
  // The one-shot path, kept for an operator running a single task by hand. It
1167
1307
  // still reconciles first: a leftover claim is a leftover claim whoever is
@@ -1186,8 +1326,9 @@ if (require.main === module) {
1186
1326
 
1187
1327
  module.exports = {
1188
1328
  readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
1189
- worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1329
+ worktreeName, readFence, gradeLedgerPath, gradeCapReader, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1190
1330
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1191
1331
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, claudeAccountLabel, RUNNER_STARTED_AT, failureReason,
1192
1332
  newRunnerState, loadedFiles, supervisorTouched, refreshCheckout, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1333
+ readRunLogRows, recheckPendingLands, LAND_SWEEP_MS,
1193
1334
  };
@@ -72,6 +72,33 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
72
72
  // fail loudly here rather than silently reclassify a held claim as untouched.
73
73
  const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
74
74
 
75
+ // LAND_GRACE_MS — how long a SHIPPED task may have nothing on main before that
76
+ // is an owner's problem (task 1004538).
77
+ //
78
+ // The one place this file deliberately waits. Everywhere else the session that
79
+ // was meant to finish the job has exited, so waiting cannot change the verdict.
80
+ // A shipped task is different: the ship is done, and what is outstanding is the
81
+ // LAND, which runs on the server's own clock and can legitimately take hours on a
82
+ // red main. Task 1004513 was flagged at 22:24Z and its merge landed ~11 hours
83
+ // later; the owner got a false alarm (blocker 1000185). A day covers that with
84
+ // room to spare, and a genuinely codeless ship is already credited, so a day's
85
+ // delay in saying so costs nothing. AUTOBONGOS_LAND_GRACE_HOURS overrides it.
86
+ const LAND_GRACE_MS = (() => {
87
+ const h = Number(process.env.AUTOBONGOS_LAND_GRACE_HOURS);
88
+ return Number.isFinite(h) && h > 0 ? h * 3600_000 : 24 * 3600_000;
89
+ })();
90
+
91
+ // When the ship happened, from the LEDGER — never from this process, so a runner
92
+ // restart cannot reset the clock. `shipped_at` first: `updated_at` moves on any
93
+ // touch. Null when neither parses, which classifyLedger reads as "no grace".
94
+ function shippedAtMs(task) {
95
+ for (const k of ['shipped_at', 'updated_at']) {
96
+ const t = Date.parse(task && task[k]);
97
+ if (Number.isFinite(t)) return t;
98
+ }
99
+ return null;
100
+ }
101
+
75
102
  // ---------------------------------------------------------------------------
76
103
  // The pure half
77
104
  // ---------------------------------------------------------------------------
@@ -92,7 +119,7 @@ const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
92
119
  // modules/ideas/blockers.js is exactly ['ready']), which is why
93
120
  // the caller releases BEFORE it links.
94
121
  // halt — the run stops here rather than picking another task.
95
- function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
122
+ function classifyLedger({ task, artifact = null, nowMs = Date.now(), landGraceMs = LAND_GRACE_MS } = {}) {
96
123
  const none = { releaseClaim: false, fileBlocker: false, linkBlocker: false, halt: false };
97
124
 
98
125
  if (!task) {
@@ -110,6 +137,19 @@ function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
110
137
 
111
138
  if (status === 'shipped') {
112
139
  if (artifact && artifact.checked && !artifact.onMain) {
140
+ // Not yet is not never (task 1004538). Inside the grace window this is a
141
+ // land still in flight: nothing is filed, and the runner re-probes it on
142
+ // later iterations (recheckLands). With no readable clock the grace cannot
143
+ // be measured, so it falls through to the strict verdict below.
144
+ const shippedAt = shippedAtMs(task);
145
+ if (shippedAt !== null && nowMs - shippedAt < landGraceMs) {
146
+ const hours = Math.round(landGraceMs / 3600_000);
147
+ return {
148
+ ...none, verified: false, shape: 'pending_land', status, pendingLand: true,
149
+ reason: 'shipped, but main does not carry a commit naming it yet — the land can take hours on a red main',
150
+ remedy: `nothing yet: the runner re-checks main on later iterations and files a blocker only if it is still missing ${hours}h after the ship`,
151
+ };
152
+ }
113
153
  // The flip happened with nothing behind it — tasks 1002459 and 1002792,
114
154
  // both credited, neither carrying a line of code on any branch. The task is
115
155
  // NOT released and NOT linked: it is shipped, credits are paid, and parking
@@ -240,26 +280,50 @@ const runGit = (args, cwd) => new Promise((resolve) => {
240
280
  //
241
281
  // Deliberately weak and deliberately cheap. It answers "is there a trace", not
242
282
  // "which commit is it": a body mention counts, because the question being asked
243
- // is the 1002459 question — was anything at all produced. The fixed-string grep
244
- // is against `(task N)`, the convention every ship commit follows.
283
+ // is the 1002459 question — was anything at all produced.
284
+ //
285
+ // WHAT COUNTS AS NAMING THE TASK (task 1004538). `(task N)` alone was too
286
+ // narrow: the commits PR 1456 landed for task 1004513 never wrote it — the merge
287
+ // subject names the ship BRANCH (`…/autobongos-1004513-978953`, or
288
+ // `…/task-1004440` for a hand ship). So the old probe said "no artifact" even
289
+ // after the land. Now git narrows by the bare id (fixed string, cheap) and
290
+ // namesTask() makes the exact call in JS, never matching the prefix of a longer
291
+ // id. The SUBJECT may name it as `task N` / `task-N` / `autobongos-N-`; the BODY
292
+ // only as `(task N)`, because bodies routinely DISCUSS other tasks ("task 1004513
293
+ // was flagged…" is in this very file's commit), and a mention is not an artifact.
245
294
  //
246
295
  // A probe that could not run returns `checked: false`, which classifyLedger
247
296
  // treats as no evidence either way.
248
- async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT } = {}) {
297
+ function namesTask(subject, body, id) {
298
+ const inSubject = new RegExp(`(?:\\btask[\\s-]+|\\bautobongos-)${id}(?!\\d)`, 'i');
299
+ const inBody = new RegExp(`\\(task ${id}\\)`, 'i');
300
+ return inSubject.test(String(subject || '')) || inBody.test(String(body || ''));
301
+ }
302
+
303
+ // `fetch: false` skips the fetch and reads the FETCH_HEAD a caller has just
304
+ // fetched itself — recheckLands fetches once per pass, not once per task.
305
+ async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT, fetch = true } = {}) {
249
306
  const id = String(taskId);
250
307
  if (!/^\d{1,19}$/.test(id)) return { checked: false, reason: 'task id is not a plain number' };
251
308
 
252
- const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
253
- if (!fetched.ok) {
254
- return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
309
+ if (fetch) {
310
+ const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
311
+ if (!fetched.ok) {
312
+ return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
313
+ }
255
314
  }
256
- const log = await git(['log', 'FETCH_HEAD', '--max-count=5', '--format=%H %s', '-F', `--grep=(task ${id})`], cwd);
315
+ // %x1f between fields, %x1e after each commit: a body may hold any text.
316
+ const log = await git(['log', 'FETCH_HEAD', '--max-count=50', '--format=%H%x1f%s%x1f%b%x1e', '-F', `--grep=${id}`], cwd);
257
317
  if (!log.ok) return { checked: false, reason: `git log failed: ${(log.stderr || '').trim().slice(-200)}` };
258
318
 
259
- const first = log.stdout.split('\n').find((l) => l.trim());
260
- if (!first) return { checked: true, onMain: false, sha: null, subject: null };
261
- const sp = first.indexOf(' ');
262
- return { checked: true, onMain: true, sha: first.slice(0, sp < 0 ? first.length : sp), subject: sp < 0 ? null : first.slice(sp + 1) };
319
+ for (const rec of log.stdout.split('\x1e')) {
320
+ const [sha, subject, body] = rec.replace(/^\s+/, '').split('\x1f');
321
+ if (!sha || !sha.trim()) continue;
322
+ if (namesTask(subject, body, id)) {
323
+ return { checked: true, onMain: true, sha: sha.trim(), subject: subject || null };
324
+ }
325
+ }
326
+ return { checked: true, onMain: false, sha: null, subject: null };
263
327
  }
264
328
 
265
329
  // fetchTask returns the row or null, and says WHY on a null. The reason is the
@@ -330,7 +394,7 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
330
394
  artifact = await probe(taskId, { cwd: deps.cwd || REPO_ROOT });
331
395
  }
332
396
 
333
- const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now() });
397
+ const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now(), ...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}) });
334
398
  const out = { ...v, task_id: String(taskId), artifact, released: false, blocker: null };
335
399
  if (readError) out.read_error = readError;
336
400
  if (!act) return out;
@@ -360,6 +424,136 @@ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}
360
424
  return out;
361
425
  }
362
426
 
427
+ // ---------------------------------------------------------------------------
428
+ // Pending lands, across iterations and restarts (task 1004538)
429
+ // ---------------------------------------------------------------------------
430
+
431
+ // pendingLandIds — which tasks are still waiting on a land, from the run log.
432
+ //
433
+ // The run log is append-only JSONL on disk, so this survives a restart; nothing
434
+ // lives only in memory. The LATEST verdict per task wins: a `worked` or
435
+ // `land_recheck` row whose ledger_shape is `pending_land` keeps it on the list,
436
+ // anything later (shipped, shipped_no_artifact, …) takes it off. The grace clock
437
+ // itself is not here — it is the ledger's `shipped_at`, re-read on every check.
438
+ function pendingLandIds(rows) {
439
+ const latest = new Map();
440
+ for (const r of rows || []) {
441
+ if (!r || typeof r !== 'object' || !r.task_id || !r.ledger_shape) continue;
442
+ if (r.event !== 'worked' && r.event !== 'land_recheck') continue;
443
+ latest.set(String(r.task_id), r.ledger_shape);
444
+ }
445
+ return Array.from(latest).filter(([, shape]) => shape === 'pending_land').map(([id]) => id);
446
+ }
447
+
448
+ const NO_ARTIFACT_REF = /^task-(\d{1,19})-shipped_no_artifact$/;
449
+
450
+ // openNoArtifactBlockers — every open blocker this file filed as
451
+ // `shipped_no_artifact`, read from the DATABASE (so a blocker filed by a
452
+ // previous process, or before this code existed, is still found).
453
+ //
454
+ // GET /blockers lists OPEN blockers only (listOpenBlockers) — the owner's
455
+ // to-do list, normally tens of rows — so one or two pages is the real cost; the
456
+ // route has no source filter, hence the client-side match. Paged by the route's
457
+ // own `page.total`. MAX_PAGES is a runaway guard, and hitting it is reported, not
458
+ // silently treated as "looked everywhere".
459
+ const BLOCKER_PAGE = 200;
460
+ const MAX_BLOCKER_PAGES = 25;
461
+ async function openNoArtifactBlockers(api) {
462
+ const out = [];
463
+ for (let page = 0; ; page++) {
464
+ if (page >= MAX_BLOCKER_PAGES) {
465
+ throw new Error(`more than ${MAX_BLOCKER_PAGES * BLOCKER_PAGE} open blockers — stopped paging; the ones past that were not checked`);
466
+ }
467
+ const offset = page * BLOCKER_PAGE;
468
+ const r = await api.blockers.getBlockers({ query: { limit: BLOCKER_PAGE, offset } });
469
+ if (!r || !r.ok) throw new Error(`GET /blockers → ${r ? r.status : 'no response'}`);
470
+ const rows = (r.data && r.data.blockers) || [];
471
+ for (const b of rows) {
472
+ const m = b && b.source === 'autobongos' && NO_ARTIFACT_REF.exec(String(b.source_ref || ''));
473
+ if (m && (!b.status || b.status === 'open')) out.push({ id: String(b.id), taskId: m[1] });
474
+ }
475
+ const total = r.data && r.data.page && Number(r.data.page.total);
476
+ if (!rows.length || (Number.isFinite(total) ? offset + rows.length >= total : rows.length < BLOCKER_PAGE)) break;
477
+ }
478
+ return out;
479
+ }
480
+
481
+ // recheckLands — one pass, run by the runner once per loop.
482
+ //
483
+ // 1. Every pending land is verified again. Inside the grace window it stays
484
+ // `pending_land` and nothing is filed; past it, verifyShip files the
485
+ // `shipped_no_artifact` blocker exactly as before. Shipped tasks are never
486
+ // released, so no release function is passed.
487
+ // 2. Every OPEN `shipped_no_artifact` blocker is re-probed, and resolved with
488
+ // a note once main carries the commit — the false alarm closes itself.
489
+ // Only a probe that actually ran and found the commit resolves anything.
490
+ //
491
+ // I/O per pass: at most ONE `git fetch` (only when there is something to probe),
492
+ // one `git log` per distinct task id, and the blocker list only when
493
+ // `sweepBlockers` is not false — the runner sweeps hourly, not every loop.
494
+ //
495
+ // Never throws: a pass that fails is reported in `errors` and retried next loop.
496
+ async function recheckLands({ pendingIds = [], sweepBlockers = true } = {}, deps = {}) {
497
+ const api = deps.api || await (require('./cli-lib').cliClient)();
498
+ const cwd = deps.cwd || REPO_ROOT;
499
+ const git = deps.git || runGit;
500
+ const out = { rechecked: [], resolved: [], errors: [] };
501
+
502
+ let open = [];
503
+ if (sweepBlockers) {
504
+ try { open = await openNoArtifactBlockers(api); }
505
+ catch (e) { out.errors.push(`blocker list: ${e && e.message ? e.message : String(e)}`); }
506
+ }
507
+
508
+ // One fetch for the whole pass; every probe then reads the same FETCH_HEAD.
509
+ // An injected probe owns its own I/O, so none is done for it.
510
+ let fetchFailed = null;
511
+ if (!deps.probeArtifact && (pendingIds.length || open.length)) {
512
+ const f = await git(['fetch', '-q', 'origin', 'main'], cwd);
513
+ if (!f.ok) fetchFailed = `git fetch failed: ${(f.stderr || '').trim().slice(-200)}`;
514
+ }
515
+ const rawProbe = deps.probeArtifact || ((id) => (fetchFailed
516
+ ? { checked: false, reason: fetchFailed }
517
+ : probeArtifact(id, { git, cwd, fetch: false })));
518
+ const memo = new Map();
519
+ const probe = (id) => {
520
+ if (!memo.has(String(id))) {
521
+ memo.set(String(id), (async () => rawProbe(id, { cwd }))()
522
+ .catch((e) => ({ checked: false, reason: e && e.message ? e.message : String(e) })));
523
+ }
524
+ return memo.get(String(id));
525
+ };
526
+
527
+ for (const id of pendingIds) {
528
+ try {
529
+ const v = await verifyShip({ taskId: id }, {
530
+ api, cwd, probeArtifact: (tid) => probe(tid),
531
+ ...(deps.fileBlocker ? { fileBlocker: deps.fileBlocker } : {}),
532
+ ...(deps.nowMs ? { nowMs: deps.nowMs } : {}),
533
+ ...(deps.landGraceMs ? { landGraceMs: deps.landGraceMs } : {}),
534
+ });
535
+ out.rechecked.push({
536
+ task_id: String(id), ledger_status: v.status, ledger_shape: v.shape, verified: !!v.verified,
537
+ ledger_reason: v.reason || null,
538
+ blocker_id: v.blocker && v.blocker.blockerId ? v.blocker.blockerId : null,
539
+ });
540
+ } catch (e) { out.errors.push(`task ${id}: ${e && e.message ? e.message : String(e)}`); }
541
+ }
542
+
543
+ for (const b of open) {
544
+ const a = await probe(b.taskId);
545
+ if (!a || !a.checked || !a.onMain) continue;
546
+ const sha = a.sha ? String(a.sha).slice(0, 9) : 'a commit';
547
+ const note = `Auto-resolved by autobongos-verify.js (task 1004538): main now carries ${sha}${a.subject ? ` ("${String(a.subject).slice(0, 120)}")` : ''}, which names task ${b.taskId}. The land was slow, not missing.`;
548
+ try {
549
+ const r = await api.blockers.postBlockersIdResolve({ id: Number(b.id), body: { resolution_note: note } });
550
+ if (r && r.ok) out.resolved.push({ blocker_id: b.id, task_id: b.taskId, sha });
551
+ else out.errors.push(`resolve blocker ${b.id} → ${r ? r.status : 'no response'}`);
552
+ } catch (e) { out.errors.push(`resolve blocker ${b.id}: ${e && e.message ? e.message : String(e)}`); }
553
+ }
554
+ return out;
555
+ }
556
+
363
557
  // ---------------------------------------------------------------------------
364
558
  // CLI
365
559
  // ---------------------------------------------------------------------------
@@ -399,4 +593,7 @@ if (require.main === module) {
399
593
  });
400
594
  }
401
595
 
402
- module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason };
596
+ module.exports = {
597
+ classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip, failureReason,
598
+ LAND_GRACE_MS, namesTask, pendingLandIds, recheckLands,
599
+ };
@@ -117,7 +117,7 @@ function parseSchemaPending(stdout) {
117
117
  * @param {string} installed the version running now
118
118
  * @param {string} channel this instance's update channel
119
119
  */
120
- function targetFault(to, available, installed, channel, channelAllows) {
120
+ function targetFault(to, available, installed, channel, channelAllows, compare) {
121
121
  if (!Array.isArray(available) || available.length === 0) {
122
122
  return `could not read the published core versions from the registry — nothing was changed; the release may still be publishing (a tag is minted before the publish completes)`;
123
123
  }
@@ -136,6 +136,18 @@ function targetFault(to, available, installed, channel, channelAllows) {
136
136
  if (!installed) {
137
137
  return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
138
138
  }
139
+ // A TARGET THE BOX HAS ALREADY REACHED IS NOT A CHANNEL QUESTION (task 1004534).
140
+ // channelAllows answers "may an unattended jump go here" and says no to anything not
141
+ // strictly newer, so on 2026-10-01 a press for 1.20.73, landed a minute earlier by the
142
+ // sweep, was reported as "outside this project's 'minor' update channel … across a major
143
+ // version or onto a prerelease" — false on every count, and it put a "your call" card on
144
+ // /deploy for a project that was simply up to date. Already there: no fault, so the
145
+ // caller's already-serving no-op answers it. Already past it: say that, in words that
146
+ // classify as a downgrade, never as the update rule.
147
+ if (installed === to) return null;
148
+ if (compare && compare(to, installed) < 0) {
149
+ return `core ${to} is older than the core this project already runs (it already runs core ${installed}) — nothing was changed. A move never goes back to an older core on its own.`;
150
+ }
139
151
  if (channelAllows && !channelAllows(installed, to, channel)) {
140
152
  return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. ${channelNextStep(channel)}`;
141
153
  }
@@ -276,7 +288,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
276
288
  const channel = instanceChannel(inst, channelMod);
277
289
  const listed = offeredVersions(channelMod, inst, instanceDir);
278
290
  const available = (listed && listed.versions) || [];
279
- const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
291
+ const fault = targetFault(to, available, installed, channel, channelMod.channelAllows, channelMod.compareSemver);
280
292
  if (fault) return failed(outcome.runnerReason(fault), fault);
281
293
  if (installed && installed === to) {
282
294
  // `bongos upgrade` would no-op on this too, but saying so here keeps a re-drained