@bongos/core 1.20.33 → 1.20.35

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/.bongos-core.json +53 -43
  2. package/.claude/skills/ask-for-help/SKILL.md +5 -3
  3. package/.claude/skills/collab-review/SKILL.md +8 -5
  4. package/clients/bongos-client/index.d.ts +1 -1
  5. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +19 -5
  6. package/docs/adr/README.md +1 -1
  7. package/docs/api/openapi.json +3 -2
  8. package/docs/copy-inventory.md +18 -17
  9. package/docs/copy-registry.json +70 -51
  10. package/docs/module-api-changelog.md +5 -1
  11. package/docs/page-readings.json +14 -14
  12. package/docs/recipes/autobongos-windows-host.md +1 -1
  13. package/modules/autonomy/cadence.js +8 -0
  14. package/modules/autonomy/gauge.js +5 -1
  15. package/modules/hall-ui/public/approval-queue.js +1 -1
  16. package/modules/hall-ui/public/collab-lib.js +10 -1
  17. package/modules/hall-ui/public/collab.js +47 -10
  18. package/modules/hall-ui/public/studio.js +7 -7
  19. package/modules/lifecycle/help-requests.js +35 -2
  20. package/modules/lifecycle/migrations/lifecycle_016_help_request_reopen.sql +23 -0
  21. package/modules/lifecycle/routes/help-requests.js +15 -1
  22. package/modules/provisioning/render-standup.js +4 -1
  23. package/modules/public-landing/public/projects.html +16 -7
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +23 -0
  27. package/scripts/gds/autobongos-loop.js +37 -1
  28. package/scripts/gds/autobongos-run.js +38 -80
  29. package/src/module-api.js +1 -1
  30. package/tests/autobongos_cadence.mjs +27 -0
  31. package/tests/autobongos_loop.mjs +107 -94
  32. package/tests/collab_page.mjs +27 -1
  33. package/tests/hall_approval_queue.mjs +5 -1
  34. package/tests/hall_client_request_shape.mjs +103 -0
  35. package/tests/hall_studio_home.mjs +7 -1
  36. package/tests/hall_studio_world.mjs +5 -1
  37. package/tests/help_requests.mjs +54 -1
  38. package/tests/projects_hub_app_step.mjs +42 -0
  39. package/tests/provisioning_render_route.mjs +7 -0
@@ -40,7 +40,7 @@ const os = require('node:os');
40
40
  const seq = require('./sequence.js');
41
41
  const gauge = require('./autonomy-gauge.js');
42
42
  const {
43
- buildWorkerArgs, buildWorkerPrompt, classifyRun, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
43
+ buildWorkerArgs, buildWorkerPrompt, classifyRun, usageLimitHit, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
44
44
  } = require('./autobongos-loop.js');
45
45
  const { verifyShip, failureReason } = require('./autobongos-verify.js');
46
46
  const fence = require('../../modules/autonomy/fence.js');
@@ -95,55 +95,6 @@ function logPath() {
95
95
  try { return require('../../src/instance-config.js').configPath('autobongos-runs.jsonl'); }
96
96
  catch (_) { return path.join(REPO_ROOT, 'autobongos-runs.jsonl'); }
97
97
  }
98
- // countWorkedSince — how many tasks this runner has worked since an epoch-second
99
- // mark. Reads the run log, because that is the only record that survives a
100
- // restart, and the cap it feeds has to hold across one (a runner that forgets
101
- // its count on every crash has no cap at all).
102
- //
103
- // BOUNDED ON BOTH AXES, because this runs every iteration of a loop that never
104
- // exits and the log only grows. It reads the TAIL rather than the file, and it
105
- // scans BACKWARDS and stops at the first row older than the mark — the rows that
106
- // can possibly count are the newest ones, so the common case touches a handful
107
- // of lines whatever the log's size.
108
- //
109
- // A torn or missing log counts as zero rather than refusing: the fence and the
110
- // gauge are the gates, this is a bound on top of them, and a bound that bricks
111
- // the runner when its own log is unreadable is worse than one that occasionally
112
- // allows an extra task.
113
- const WORKED_SCAN_TAIL_BYTES = 1024 * 1024;
114
- const NEWLINE = String.fromCharCode(10);
115
-
116
- function countWorkedSince(sinceEpochS) {
117
- if (!Number.isFinite(sinceEpochS) || sinceEpochS <= 0) return 0;
118
- let text;
119
- try {
120
- const p = logPath();
121
- const { size } = fs.statSync(p);
122
- const from = Math.max(0, size - WORKED_SCAN_TAIL_BYTES);
123
- const fd = fs.openSync(p, 'r');
124
- try {
125
- const buf = Buffer.alloc(Math.min(size, WORKED_SCAN_TAIL_BYTES));
126
- fs.readSync(fd, buf, 0, buf.length, from);
127
- text = buf.toString('utf8');
128
- } finally { fs.closeSync(fd); }
129
- // A tail read can land mid-line; drop the first partial one.
130
- if (from > 0) text = text.slice(text.indexOf(NEWLINE) + 1);
131
- } catch (_) { return 0; }
132
-
133
- const lines = text.split(NEWLINE);
134
- let n = 0;
135
- for (let i = lines.length - 1; i >= 0; i--) {
136
- const line = lines[i];
137
- if (!line.trim()) continue;
138
- let row;
139
- try { row = JSON.parse(line); } catch (_) { continue; } // a torn line costs one row, not the count
140
- const at = Date.parse(row.at);
141
- if (!Number.isFinite(at)) continue;
142
- if (at / 1000 < sinceEpochS) break; // the log is append-ordered: nothing older can count
143
- if (row.event === 'worked') n += 1;
144
- }
145
- return n;
146
- }
147
98
 
148
99
  function record(entry) {
149
100
  const row = { at: new Date().toISOString(), ...entry };
@@ -401,9 +352,14 @@ const CLAIM_SET_ASIDE_MS = 30 * 60 * 1000;
401
352
  // A bound on claim attempts in ONE pass, so a queue of refusals cannot turn one
402
353
  // iteration into dozens of worktree creations.
403
354
  const MAX_CLAIM_ATTEMPTS = 5;
355
+ // A limit message with no machine-readable reset (the CLI's human forms name a
356
+ // clock time in a zone we would have to guess). Half an hour is short against a
357
+ // five-hour window and long against a spawn that is cut off in two seconds, so a
358
+ // guess that is wrong costs one wasted spawn per half hour, not one per wake.
359
+ const LIMIT_UNKNOWN_RESET_MS = 30 * 60 * 1000;
404
360
 
405
361
  function newRunnerState() {
406
- return { setAside: new Map(), queueGate: null };
362
+ return { setAside: new Map(), queueGate: null, limitUntil: null };
407
363
  }
408
364
  const RUNNER_STATE = newRunnerState();
409
365
 
@@ -474,42 +430,30 @@ async function iteration(opts, deps = {}) {
474
430
  return log({ event: 'hold', go: false, unknown: !!g.decision.unknown, reason: g.decision.reason, sleep_until: g.decision.sleepUntil ?? null });
475
431
  }
476
432
 
477
- // 1b. A DERIVED gauge buys a BOUNDED amount of work (task 1004178).
478
- //
479
- // `derived` means the last real reading's window had already reset, so the
480
- // baseline is a retired observation rather than a measurement. That is sound
481
- // for the FIVE-hour window, which turns over during a night. It does nothing
482
- // for the SEVEN-day one, which does not — so the weekly pace check, the thing
483
- // the module's notes call what actually binds a heavy week, is simply absent
484
- // on a derived reading. A stale weekly figure cannot substitute: usage only
485
- // grows, so an under-reported value makes the check more permissive, and that
486
- // is the wrong direction to be wrong in.
487
- //
488
- // So the bound is a task count, not a percentage: on one derived window, do
489
- // AUTOBONGOS_DERIVED_TASK_CAP tasks and then hold until a real reading exists.
490
- // A terminal session writes one; the owner opening a terminal in the morning
491
- // is what lifts it. Default 2 — each worker is wall-clock bounded at 90
492
- // minutes, so two of them is well inside a five-hour window even at worst, and
493
- // the cap only has to be small enough that being wrong is survivable.
494
- if (g.decision.derived) {
495
- const cap = Number(process.env.AUTOBONGOS_DERIVED_TASK_CAP ?? 2);
496
- const since = Number(g.decision.derivedSince) || 0;
497
- const done = countWorkedSince(since);
498
- if (Number.isFinite(cap) && cap >= 0 && done >= cap) {
433
+ // 1b. The usage limit, once HIT (task 1004454). Owner ruling 2026-09-30: no
434
+ // fixed cap on how much work a reading buys — the gauge above is read before
435
+ // every task and a derived reading proceeds — and hitting the limit is
436
+ // acceptable, so long as the runner then waits for the reset and resumes by
437
+ // itself. A worker cut off by the limit records when it resets; until then no
438
+ // task is taken, because every worker spawned would be cut off the same way.
439
+ const state = deps.state || RUNNER_STATE;
440
+ const now = deps.now ? deps.now() : Date.now();
441
+ if (state.limitUntil) {
442
+ if (now < state.limitUntil) {
499
443
  return log({
500
- event: 'hold', go: false, unknown: false, derived: true, worked_since_derived: done,
501
- reason: `derived gauge: ${done} task(s) already done since the window reset and the cap is ${cap} — holding for a real reading (a terminal session writes one)`,
502
- sleep_until: null,
444
+ event: 'hold', go: false, unknown: false, usage_limit: true,
445
+ reason: 'the subscription usage limit was hit — sleeping to its reset, then resuming by itself',
446
+ sleep_until: Math.round(state.limitUntil / 1000),
503
447
  });
504
448
  }
449
+ log({ event: 'usage_limit_reset', was_until: new Date(state.limitUntil).toISOString() });
450
+ state.limitUntil = null;
505
451
  }
506
452
 
507
453
  // 1c. A QUEUE-WIDE gate from an earlier pass (task 1004396). While the
508
454
  // stranded task it named is still `confirmed`, every claim would be refused, so
509
455
  // none is attempted. The wait wakes early when main moves (waitOrJump), which is
510
456
  // exactly what the strand landing does.
511
- const state = deps.state || RUNNER_STATE;
512
- const now = deps.now ? deps.now() : Date.now();
513
457
  if (state.queueGate) {
514
458
  if (await queueGateHolds(state.queueGate, deps)) return log(queueGatedRow(state.queueGate, now));
515
459
  log({ event: 'queue_gate_cleared', waited_on: state.queueGate.owed, waited_s: Math.max(0, Math.round((now - state.queueGate.since) / 1000)) });
@@ -592,6 +536,10 @@ async function iteration(opts, deps = {}) {
592
536
  const started = Date.now();
593
537
  const res = await spawnWork({ task, model: opts.model, timeoutMs: opts.timeoutMs, cwd: wtPath, deps });
594
538
  const verdict = classifyRun(res);
539
+ // Cut off by the usage limit? Recorded here so the NEXT iteration sleeps to the
540
+ // reset instead of spawning a worker that will be cut off the same way.
541
+ const limit = verdict.outcome === 'claims_shipped' ? null : usageLimitHit({ envelope: res.envelope, stderr: res.stderr });
542
+ if (limit) state.limitUntil = limit.resetAt ? limit.resetAt * 1000 : now + LIMIT_UNKNOWN_RESET_MS;
595
543
 
596
544
  // 6. VERIFY FROM THE LEDGER (task 1003903). Read the task back from Bongos and
597
545
  // let IT say what happened — for every outcome, not only a claimed ship. The
@@ -623,6 +571,7 @@ async function iteration(opts, deps = {}) {
623
571
  session_id: res.sessionId || null, duration_s: Math.round((Date.now() - started) / 1000),
624
572
  cost_usd: verdict.costUsd ?? null,
625
573
  output_truncated: !!res.truncated,
574
+ ...(limit ? { usage_limit: { reset_at: limit.resetAt } } : {}),
626
575
  undetermined_decisions: verdict.verdict ? verdict.verdict.undetermined_decisions : null,
627
576
  // What the LEDGER says, kept separate from what the worker said, so the run
628
577
  // log can be read afterwards without having to trust either one alone.
@@ -1022,6 +971,15 @@ async function forever(opts, deps = {}) {
1022
971
  // corpse.
1023
972
  await beat({ mode: state.mode, last_event: row.event, working_task_id: Number(row.task_id) || undefined });
1024
973
  const seen = cadence.classifyEvent(row);
974
+ // A hold that names its reset SECOND waits until that second (task 1004454).
975
+ // classifyEvent carries it as an absolute epoch; the cadence has no clock of
976
+ // its own, so the conversion to a wait lives here beside the one clock read.
977
+ // Without it every hold waited the fixed idle and the "wake at the reset"
978
+ // the goal asks for was only ever as accurate as the poll.
979
+ if (Number.isFinite(Number(seen.sleepUntil)) && seen.sleepUntil !== null) {
980
+ const nowS = Math.floor((deps.now ? deps.now() : Date.now()) / 1000);
981
+ seen.sleepUntilS = Math.max(0, Number(seen.sleepUntil) - nowS);
982
+ }
1025
983
  state = cadence.nextCadence(state, seen, opts.cadence);
1026
984
  log({ event: 'cadence', mode: state.mode, consecutive_failures: state.consecutiveFailures, wait_s: state.waitS, class: seen.class, why: state.why });
1027
985
  // A burst continues only while work is actually landing. Anything else ends
@@ -1082,8 +1040,8 @@ if (require.main === module) {
1082
1040
 
1083
1041
  module.exports = {
1084
1042
  readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
1085
- worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, countWorkedSince, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1043
+ worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
1086
1044
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
1087
1045
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
1088
- newRunnerState, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1046
+ newRunnerState, LIMIT_UNKNOWN_RESET_MS, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
1089
1047
  };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.33'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -293,6 +293,33 @@ function loopHarness({ rows, maxLoops = 6 }) {
293
293
  return { deps, waits: emitted, recorded, opts: { goals: [], maxTasks: 3, maxLoops } };
294
294
  }
295
295
 
296
+ // ── the usage limit: a wait to a known second, not a failure (task 1004454) ──
297
+
298
+ test('a worker cut off by the usage limit is quiet, not a failed launch', () => {
299
+ // It exits in seconds having spent nothing — exactly workerNeverRan()'s shape —
300
+ // but the machine is fine: the window is spent. Escalating it would put the
301
+ // runner into probing for a limit the owner has said is acceptable to hit.
302
+ const seen = classifyEvent({ event: 'worked', outcome: 'worker_failed', duration_s: 2, cost_usd: null, reason: 'x', usage_limit: { reset_at: 1790300000 } });
303
+ assert.equal(seen.class, 'quiet');
304
+ assert.equal(seen.sleepUntil, 1790300000);
305
+ });
306
+
307
+ test('a hold with a reset time makes the loop wait until THAT second, not a fixed idle', async () => {
308
+ const nowS = 1_790_290_000;
309
+ const h = loopHarness({ rows: [{ event: 'hold', go: false, reason: 'five-hour burn at the ceiling', sleep_until: nowS + 3600 }], maxLoops: 1 });
310
+ h.deps.now = () => nowS * 1000;
311
+ await runner.forever(h.opts, h.deps);
312
+ assert.equal(h.waits[0].waitS, 3600, 'the wake is the reset second the gauge named');
313
+ });
314
+
315
+ test('a hold whose reset has already passed waits no time at all', async () => {
316
+ const nowS = 1_790_290_000;
317
+ const h = loopHarness({ rows: [{ event: 'hold', go: false, reason: 'x', sleep_until: nowS - 5 }], maxLoops: 1 });
318
+ h.deps.now = () => nowS * 1000;
319
+ await runner.forever(h.opts, h.deps);
320
+ assert.equal(h.waits[0].waitS, 0);
321
+ });
322
+
296
323
  test('PULLING THE NETWORK: the loop backs off and never returns', async () => {
297
324
  const h = loopHarness({ rows: [{ event: 'pick_failed', reason: 'fetch failed ECONNREFUSED' }] });
298
325
  await runner.forever(h.opts, h.deps);
@@ -11,7 +11,7 @@ import { createRequire } from 'node:module';
11
11
 
12
12
  const require = createRequire(import.meta.url);
13
13
  const {
14
- WORKER_VERDICT_SCHEMA, buildWorkerArgs, buildWorkerPrompt, parseWorkerEnvelope, classifyRun,
14
+ WORKER_VERDICT_SCHEMA, buildWorkerArgs, buildWorkerPrompt, parseWorkerEnvelope, classifyRun, usageLimitHit,
15
15
  } = require('../scripts/gds/autobongos-loop.js');
16
16
  const runner = require('../scripts/gds/autobongos-run.js');
17
17
 
@@ -836,119 +836,132 @@ test('workerEnv passes Claude auth through, and still refuses the rest', () => {
836
836
  assert.equal(env[leak], undefined, `${leak} must never reach a bypassPermissions worker`);
837
837
  }
838
838
  });
839
- // --- a derived gauge buys BOUNDED work (task 1004178) -----------------------
839
+ // --- usage is judged continuously; hitting the limit sleeps to its reset (task 1004454) ---
840
840
  //
841
- // `derived` retires a reading whose window has reset. That is sound for the
842
- // five-hour window, which turns over during a night. The SEVEN-day one does
843
- // not, so its stale reading is dropped and the weekly pace check — what the
844
- // module's notes call the thing that actually binds a heavy week — does not run
845
- // at all on a derived reading. The task cap is what stands in for it, so the
846
- // cap has to work, and it has to survive a restart: the count comes from the
847
- // run log, not from memory.
841
+ // Owner ruling, 2026-09-30: no fixed cap. The derived-gauge cap (task 1004178)
842
+ // held the runner after two tasks on an inferred reading — 776 holds in one
843
+ // 2026-09-26..29 run — for a risk the owner accepts: "if you hit usage limits
844
+ // that's fine, but then restart once the limit resets". So the gauge is still
845
+ // read before every task, a derived reading proceeds, and the limit itself is
846
+ // the stop: a worker cut off by it puts the runner to sleep until the reset.
848
847
 
849
848
  import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
850
849
  import { tmpdir } from 'node:os';
851
850
  import { join } from 'node:path';
852
851
 
853
- const derivedLog = () => {
854
- const dir = mkdtempSync(join(tmpdir(), 'autobongos-cap-'));
855
- return join(dir, 'runs.jsonl');
856
- };
857
-
858
- test('countWorkedSince counts only worked rows at or after the mark', () => {
859
- const p = derivedLog();
860
- process.env.AUTOBONGOS_LOG_FILE = p;
861
- const at = (s) => new Date(s * 1000).toISOString();
862
- writeFileSync(p, [
863
- JSON.stringify({ at: at(1000), event: 'worked', task_id: '1' }), // before the mark
864
- JSON.stringify({ at: at(3000), event: 'hold' }), // not a worked row
865
- JSON.stringify({ at: at(3100), event: 'worked', task_id: '2' }),
866
- '{ this line is torn', // costs one row, not the count
867
- JSON.stringify({ at: at(3200), event: 'worked', task_id: '3' }),
868
- ].join('\n'));
869
- assert.equal(runner.countWorkedSince(2000), 2);
870
- assert.equal(runner.countWorkedSince(0), 0, 'a zero/absent mark counts nothing rather than everything');
871
- delete process.env.AUTOBONGOS_LOG_FILE;
872
- });
873
-
874
- test('countWorkedSince treats an unreadable log as zero, not as a refusal', () => {
875
- process.env.AUTOBONGOS_LOG_FILE = join(tmpdir(), 'autobongos-cap-nope', 'missing.jsonl');
876
- assert.equal(runner.countWorkedSince(1), 0,
877
- 'the fence and the gauge are the gates; a bound that bricks the runner when its own log is gone is worse');
878
- delete process.env.AUTOBONGOS_LOG_FILE;
879
- });
880
-
881
- test('a derived gauge HOLDS once the cap is reached, and the reason says why', async () => {
882
- const p = derivedLog();
883
- process.env.AUTOBONGOS_LOG_FILE = p;
884
- process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
852
+ test('a derived gauge keeps WORKING however many tasks it has already done — there is no cap', async () => {
853
+ const dir = mkdtempSync(join(tmpdir(), 'autobongos-nocap-'));
854
+ process.env.AUTOBONGOS_LOG_FILE = join(dir, 'runs.jsonl');
885
855
  const at = (s) => new Date(s * 1000).toISOString();
886
- writeFileSync(p, [
887
- JSON.stringify({ at: at(5100), event: 'worked', task_id: '1' }),
888
- JSON.stringify({ at: at(5200), event: 'worked', task_id: '2' }),
889
- ].join('\n'));
890
-
856
+ const rows = [];
857
+ for (let i = 0; i < 6; i++) rows.push(JSON.stringify({ at: at(5100 + i), event: 'worked', task_id: String(i) }));
858
+ writeFileSync(process.env.AUTOBONGOS_LOG_FILE, rows.join('\n'));
859
+ let reachedPicker = false;
891
860
  const events = [];
892
861
  await runner.iteration({ goals: [], maxTasks: 1 }, {
862
+ state: runner.newRunnerState(),
893
863
  readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
894
864
  gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
895
- pickTask: async () => { throw new Error('CAP BREACHED: reached the picker'); },
865
+ pickTask: async () => { reachedPicker = true; return { none: true, skipped: [] }; },
896
866
  record: (row) => { events.push(row); return row; },
897
- run: async () => { throw new Error('CAP BREACHED: ran a command'); },
898
- spawnWorker: async () => { throw new Error('CAP BREACHED: spawned a worker'); },
899
867
  });
900
- assert.equal(events[0].event, 'hold');
901
- assert.equal(events[0].derived, true);
902
- assert.equal(events[0].worked_since_derived, 2);
903
- assert.match(events[0].reason, /cap is 2/);
904
- delete process.env.AUTOBONGOS_LOG_FILE; delete process.env.AUTOBONGOS_DERIVED_TASK_CAP;
905
- });
906
-
907
- test('a derived gauge still WORKS while under the cap', async () => {
908
- const p = derivedLog();
909
- process.env.AUTOBONGOS_LOG_FILE = p;
910
- process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
911
- writeFileSync(p, JSON.stringify({ at: new Date(5100 * 1000).toISOString(), event: 'worked', task_id: '1' }));
912
- let reachedPicker = false;
868
+ assert.equal(reachedPicker, true, 'six tasks since the reset must not stop the seventh');
869
+ assert.ok(!events.some((e) => e.event === 'hold'), 'no hold on a derived reading');
870
+ delete process.env.AUTOBONGOS_LOG_FILE;
871
+ });
872
+
873
+ test('the gauge is still read before EVERY task, and a real hold still holds', async () => {
874
+ let reads = 0;
913
875
  const events = [];
914
- await runner.iteration({ goals: [], maxTasks: 1 }, {
876
+ const d = {
877
+ state: runner.newRunnerState(),
915
878
  readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
916
- gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
917
- pickTask: async () => { reachedPicker = true; return { none: true, skipped: [] }; },
879
+ gauge: () => { reads += 1; return { decision: { go: false, unknown: false, reason: 'five-hour burn 80% is past the ceiling', sleepUntil: 1790218521 } }; },
880
+ pickTask: async () => { throw new Error('picked past a hold'); },
918
881
  record: (row) => { events.push(row); return row; },
919
- });
920
- assert.equal(reachedPicker, true, 'one task done against a cap of two must still proceed');
921
- delete process.env.AUTOBONGOS_LOG_FILE; delete process.env.AUTOBONGOS_DERIVED_TASK_CAP;
882
+ };
883
+ await runner.iteration({ goals: [], maxTasks: 1 }, d);
884
+ await runner.iteration({ goals: [], maxTasks: 1 }, d);
885
+ assert.equal(reads, 2);
886
+ assert.equal(events[1].event, 'hold');
887
+ assert.equal(events[1].sleep_until, 1790218521);
922
888
  });
923
889
 
924
- test('countWorkedSince is bounded: a huge log does not mean a huge scan', () => {
925
- const p = derivedLog();
926
- process.env.AUTOBONGOS_LOG_FILE = p;
927
- const at = (s) => new Date(s * 1000).toISOString();
928
- // 40k old rows, then the two that matter. A whole-file parse would touch
929
- // every one of them on EVERY iteration of a loop that never exits.
930
- const old = [];
931
- for (let i = 0; i < 40000; i++) old.push(JSON.stringify({ at: at(1000 + i), event: 'worked', task_id: String(i) }));
932
- old.push(JSON.stringify({ at: at(900000), event: 'worked', task_id: 'recent-1' }));
933
- old.push(JSON.stringify({ at: at(900100), event: 'worked', task_id: 'recent-2' }));
934
- writeFileSync(p, old.join('\n'));
935
- const t0 = Date.now();
936
- assert.equal(runner.countWorkedSince(800000), 2, 'only the rows at or after the mark count');
937
- assert.ok(Date.now() - t0 < 500, 'and it must not walk the whole log to say so');
938
- delete process.env.AUTOBONGOS_LOG_FILE;
890
+ test('usageLimitHit reads the reset second out of the CLI\'s own limit message', () => {
891
+ const env = JSON.stringify({ type: 'result', subtype: 'success', is_error: true, result: 'Claude AI usage limit reached|1790300000' });
892
+ assert.deepEqual(usageLimitHit({ envelope: env }), { resetAt: 1790300000 });
939
893
  });
940
894
 
941
- test('countWorkedSince stops at the first row older than the mark', () => {
942
- const p = derivedLog();
943
- process.env.AUTOBONGOS_LOG_FILE = p;
944
- const at = (s) => new Date(s * 1000).toISOString();
945
- writeFileSync(p, [
946
- JSON.stringify({ at: at(100), event: 'worked', task_id: 'ancient' }),
947
- JSON.stringify({ at: at(5000), event: 'worked', task_id: 'before-mark' }),
948
- JSON.stringify({ at: at(9000), event: 'worked', task_id: 'after-mark' }),
949
- ].join('\n'));
950
- assert.equal(runner.countWorkedSince(8000), 1);
951
- delete process.env.AUTOBONGOS_LOG_FILE;
895
+ test('usageLimitHit recognises a limit message with no machine-readable time, and says so', () => {
896
+ const env = JSON.stringify({ type: 'result', is_error: true, result: "You've hit your limit · resets 3am (America/New_York)" });
897
+ assert.deepEqual(usageLimitHit({ envelope: env }), { resetAt: null });
898
+ assert.deepEqual(usageLimitHit({ envelope: '', stderr: '5-hour limit reached ∙ resets 3pm' }), { resetAt: null });
899
+ });
900
+
901
+ test('usageLimitHit ignores the word "limit" in a worker that FINISHED', () => {
902
+ // A worker that fixed a GitHub rate-limit bug writes about rate limits in its
903
+ // verdict. Only an ERROR envelope, or a run with no envelope at all, can be a
904
+ // limit hit — anything else would put the runner to sleep for doing its job.
905
+ const env = JSON.stringify({ type: 'result', is_error: false, result: 'Claude AI usage limit reached|1790300000 is the banner text I fixed', structured_output: { outcome: 'shipped', what_happened: 'fixed the usage limit reached banner' } });
906
+ assert.equal(usageLimitHit({ envelope: env }), null);
907
+ assert.equal(usageLimitHit({ envelope: JSON.stringify({ is_error: true, result: 'API Error: 500' }) }), null);
908
+ });
909
+
910
+ function limitHarness(state, now, envelopeText) {
911
+ const rows = [];
912
+ const deps = {
913
+ state, now: () => now.t,
914
+ gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 1, reason: 'derived' } }),
915
+ readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 1000119 }] }, graderBypassed: false }),
916
+ api: {},
917
+ verifyDeps: {
918
+ task: { id: 4242, status: 'active', updated_at: new Date().toISOString() },
919
+ probeArtifact: async () => ({ checked: true, onMain: false }),
920
+ fileBlocker: async () => ({ filed: false }),
921
+ },
922
+ release: async () => ({ ok: true, code: 0, stdout: '', stderr: '' }),
923
+ pickTask: async () => ({ task: { id: 4242, title: 't', kind: 'feature', description: 'd'.repeat(300), goal_id: 1000119, touches: [] }, goalId: 1000119, skipped: [] }),
924
+ worktreeName: () => 'autobongos-4242-abc123',
925
+ record: (r) => { rows.push(r); return r; },
926
+ run: async () => ({ ok: true, code: 0, stdout: '', stderr: '' }),
927
+ spawnWorker: async () => ({ envelope: envelopeText, exitCode: 1, sessionId: 's' }),
928
+ };
929
+ return { deps, rows };
930
+ }
931
+
932
+ test('a worker cut off by the usage limit puts the runner to sleep until the reset, then it resumes by itself', async () => {
933
+ const state = runner.newRunnerState();
934
+ const now = { t: 1_790_290_000_000 };
935
+ const env = JSON.stringify({ type: 'result', is_error: true, result: 'Claude AI usage limit reached|1790300000' });
936
+ const h = limitHarness(state, now, env);
937
+
938
+ const worked = await runner.iteration(opts, h.deps);
939
+ assert.equal(worked.event, 'worked');
940
+ assert.deepEqual(worked.usage_limit, { reset_at: 1790300000 }, 'the row says the limit was hit and when it resets');
941
+
942
+ let picked = false;
943
+ const asleep = await runner.iteration(opts, { ...h.deps, pickTask: async () => { picked = true; return { none: true }; } });
944
+ assert.equal(asleep.event, 'hold');
945
+ assert.equal(asleep.sleep_until, 1790300000, 'it sleeps to the reset second the CLI named');
946
+ assert.match(asleep.reason, /usage limit/);
947
+ assert.equal(picked, false, 'no task is taken while the limit is spent');
948
+
949
+ now.t = 1_790_300_001_000;
950
+ const awake = await runner.iteration(opts, { ...h.deps, pickTask: async () => { picked = true; return { none: true, skipped: [] }; } });
951
+ assert.equal(picked, true, 'past the reset it picks again without anyone touching it');
952
+ assert.equal(awake.event, 'nothing_claimable');
953
+ assert.equal(state.limitUntil, null);
954
+ });
955
+
956
+ test('a limit hit with no stated reset sleeps a bounded while and then tries again', async () => {
957
+ const state = runner.newRunnerState();
958
+ const now = { t: 2_000_000_000_000 };
959
+ const h = limitHarness(state, now, JSON.stringify({ is_error: true, result: '5-hour limit reached ∙ resets 3pm' }));
960
+ const worked = await runner.iteration(opts, h.deps);
961
+ assert.deepEqual(worked.usage_limit, { reset_at: null });
962
+ const asleep = await runner.iteration(opts, h.deps);
963
+ assert.equal(asleep.event, 'hold');
964
+ assert.equal(asleep.sleep_until, Math.round((now.t + runner.LIMIT_UNKNOWN_RESET_MS) / 1000));
952
965
  });
953
966
 
954
967
  // --- a --forever runner must be able to take a fix (task 1004374) -----------
@@ -865,9 +865,35 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
865
865
  // longer "you cannot", it is "you do not, because you are not the one who read
866
866
  // the answer", plus the half that did not widen.
867
867
  assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
868
- assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
868
+ // Task 1004458 (owner direction): a session may resolve an ask, but only when
869
+ // the reader says so — never on its own judgement — and reopening is the asker's.
870
+ assert.match(md, /Resolve only when told to/, 'it closes only on the reader\'s instruction');
871
+ assert.match(md, /"status":"open"/, 'and knows the asker can reopen');
869
872
  assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
870
873
  assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
871
874
  'the file-a-counter-ask workaround is deleted, not left beside the real thing');
872
875
  assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
873
876
  });
877
+
878
+ // ---- resolve + reopen, ticket style (task 1004458) --------------------------
879
+ test('canReopen: only the asker, and only a settled ask', () => {
880
+ const L = lib();
881
+ const settled = { requested_by: '90', needs_builder_id: '7', status: 'answered' };
882
+ assert.equal(L.canReopen(settled, 90), true, 'the asker may reopen');
883
+ assert.equal(L.canReopen({ ...settled, status: 'withdrawn' }, '90'), true, 'their own withdrawal too');
884
+ assert.equal(L.canReopen(settled, 7), false, 'the addressee may not — they have a reply box');
885
+ assert.equal(L.canReopen({ ...settled, status: 'open' }, 90), false, 'an open ask has nothing to reopen');
886
+ assert.equal(L.canReopen(settled, null), false, 'no viewer, no control');
887
+ assert.equal(L.canReopen(null, 90), false);
888
+ });
889
+
890
+ test('the page offers Mark resolved, Post & resolve and Reopen, each where it can work', () => {
891
+ const js = read('modules', 'hall-ui', 'public', 'collab.js').replace(/^\s*\/\/.*$/gm, '');
892
+ assert.match(js, />Mark resolved</, 'the row control says what it does');
893
+ assert.match(js, /L\.canClose\(req, meId, myCrafts\)\s*\?\s*`<button[^`]*data-reply-resolve/, 'Post & resolve is drawn only for someone who may close');
894
+ assert.match(js, /L\.canReopen\(req, meId\)/, 'Reopen is drawn only for the asker');
895
+ assert.match(js, /settle\(Number\(reopenBtn\.getAttribute\('data-id'\)\), 'open'\)/, 'and it sends status open');
896
+ // The close waits for the reply: a refused reply must not leave an ask closed.
897
+ const post = js.slice(js.indexOf('async function postReply'), js.indexOf('function toggleReply'));
898
+ assert.ok(post.indexOf('if (!r.ok)') < post.indexOf("status: 'answered'"), 'resolve runs only after the reply landed');
899
+ });
@@ -92,7 +92,11 @@ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
92
92
  get activeElement() { return boot.focused || null; },
93
93
  };
94
94
  const api = {
95
- async request(method, u, reqBody) {
95
+ // The REAL client's shape: request(method, path, { body }). This fake once
96
+ // took the payload as the third argument, which is exactly the mistake the
97
+ // panel made — so the suite agreed with a write that sent nothing (task 1004458).
98
+ async request(method, u, opts) {
99
+ const reqBody = opts && opts.body;
96
100
  requests.push({ method, url: u, body: reqBody });
97
101
  const key = `${method} ${u}`;
98
102
  const hit = answers[key];
@@ -0,0 +1,103 @@
1
+ // tests/hall_client_request_shape.mjs — every write a hall page sends through the
2
+ // generated client's escape hatch carries its payload UNDER `body` (task 1004458).
3
+ //
4
+ // `api.request(method, path, opts)` reads `opts.body` as the JSON payload and
5
+ // nothing else. Handing it the payload itself fails two quiet ways, and both
6
+ // shipped: `{ decision }` (the mingle buttons) and `{ reason }` / `{ version_id,
7
+ // … }` (the studio's verdicts) have no `body` key, so the request goes out EMPTY;
8
+ // and `{ body: text }` (the Collab reply) sends a bare JSON string, which the
9
+ // server's strict parser refuses as `bad_json`. Neither is visible until someone
10
+ // presses the button on the live site.
11
+ //
12
+ // So this RUNS the real client against a recording fetch to pin what `body`
13
+ // means, then scans every hall-side call for an options object whose keys are
14
+ // not options.
15
+ import { strict as assert } from 'node:assert';
16
+ import { test } from 'node:test';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const OPTION_KEYS = new Set(['body', 'query', 'headers', 'hasBody', 'cache']);
23
+
24
+ function client() {
25
+ const sent = [];
26
+ const win = {};
27
+ const src = fs.readFileSync(path.join(ROOT, 'clients', 'bongos-client', 'bongos-client.global.js'), 'utf8');
28
+ new Function('window', 'globalThis', 'self', src)(win, win, win);
29
+ const fetch = async (url, init) => { sent.push({ url, init }); return { ok: true, status: 200, text: async () => '{}' }; };
30
+ return { sent, api: win.BongosClient.createClient({ fetch, throwOnError: false }) };
31
+ }
32
+
33
+ test('the escape hatch sends opts.body as the payload, and nothing else', async () => {
34
+ const { sent, api } = client();
35
+ await api.request('POST', '/x', { body: { body: 'hello' } });
36
+ assert.equal(sent[0].init.body, '{"body":"hello"}', 'a nested object arrives as an object');
37
+ await api.request('POST', '/x', { decision: 'accepted' });
38
+ assert.equal(sent[1].init.body, undefined, 'a payload passed as options is DROPPED — this is the trap');
39
+ });
40
+
41
+ // Every `api.request('POST'|'PATCH'|'PUT'|'DELETE', …)` in a hall-side file.
42
+ function hallFiles() {
43
+ const out = [];
44
+ for (const mod of fs.readdirSync(path.join(ROOT, 'modules'))) {
45
+ const dir = path.join(ROOT, 'modules', mod, 'public');
46
+ if (!fs.existsSync(dir)) continue;
47
+ for (const f of fs.readdirSync(dir)) if (f.endsWith('.js')) out.push(path.join(dir, f));
48
+ }
49
+ return out;
50
+ }
51
+
52
+ // The top-level arguments of the call starting at `open` (the index of its "(").
53
+ function argsAt(src, open) {
54
+ const args = [];
55
+ let depth = 0; let cur = ''; let quote = null;
56
+ for (let i = open + 1; i < src.length; i++) {
57
+ const c = src[i];
58
+ if (quote) { cur += c; if (c === '\\') { cur += src[++i]; } else if (c === quote) quote = null; continue; }
59
+ if (c === '"' || c === "'" || c === '`') { quote = c; cur += c; continue; }
60
+ if ('({['.includes(c)) depth++;
61
+ if (')}]'.includes(c)) { if (depth === 0) { args.push(cur.trim()); return args; } depth--; }
62
+ if (c === ',' && depth === 0) { args.push(cur.trim()); cur = ''; continue; }
63
+ cur += c;
64
+ }
65
+ return args;
66
+ }
67
+
68
+ // The keys of an object literal's top level: `{ a, b: 1, ...c }` → ['a', 'b', '...'].
69
+ function topKeys(obj) {
70
+ const inner = obj.trim().replace(/^\{/, '').replace(/\}$/, '');
71
+ return argsAt(`(${inner})`, 0).filter(Boolean).map((part) => {
72
+ if (part.startsWith('...')) return '...';
73
+ const m = /^([A-Za-z_$][\w$]*)\s*(?::|$)/.exec(part);
74
+ return m ? m[1] : part;
75
+ });
76
+ }
77
+
78
+ test('no hall page hands the client a payload where it expects options', () => {
79
+ const bad = [];
80
+ let seen = 0;
81
+ for (const file of hallFiles()) {
82
+ const src = fs.readFileSync(file, 'utf8');
83
+ const re = /\bapi\.request\(\s*'(POST|PATCH|PUT|DELETE)'/g;
84
+ let m;
85
+ while ((m = re.exec(src))) {
86
+ seen++;
87
+ const args = argsAt(src, src.indexOf('(', m.index));
88
+ const where = `${path.relative(ROOT, file)}:${src.slice(0, m.index).split('\n').length}`;
89
+ const opts = args[2];
90
+ if (!opts) continue;
91
+ if (!opts.startsWith('{')) { bad.push(`${where} passes \`${opts}\` — wrap it as { body: … }`); continue; }
92
+ const unknown = topKeys(opts).filter((k) => !OPTION_KEYS.has(k));
93
+ if (unknown.length) bad.push(`${where} passes ${unknown.join(', ')} as options — they are dropped; nest them under body`);
94
+ }
95
+ }
96
+ assert.ok(seen > 10, `the scan found the calls it is guarding (${seen})`);
97
+ assert.deepEqual(bad, [], bad.join('\n'));
98
+ });
99
+
100
+ test('the Collab reply nests its `body` field inside the payload', () => {
101
+ const js = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'collab.js'), 'utf8');
102
+ assert.match(js, /\/replies`, \{ body: \{ body: v\.body \} \}\)/, 'a bare string is refused by the server as bad_json');
103
+ });