@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.
- package/.bongos-core.json +53 -43
- package/.claude/skills/ask-for-help/SKILL.md +5 -3
- package/.claude/skills/collab-review/SKILL.md +8 -5
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +19 -5
- package/docs/adr/README.md +1 -1
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +18 -17
- package/docs/copy-registry.json +70 -51
- package/docs/module-api-changelog.md +5 -1
- package/docs/page-readings.json +14 -14
- package/docs/recipes/autobongos-windows-host.md +1 -1
- package/modules/autonomy/cadence.js +8 -0
- package/modules/autonomy/gauge.js +5 -1
- package/modules/hall-ui/public/approval-queue.js +1 -1
- package/modules/hall-ui/public/collab-lib.js +10 -1
- package/modules/hall-ui/public/collab.js +47 -10
- package/modules/hall-ui/public/studio.js +7 -7
- package/modules/lifecycle/help-requests.js +35 -2
- package/modules/lifecycle/migrations/lifecycle_016_help_request_reopen.sql +23 -0
- package/modules/lifecycle/routes/help-requests.js +15 -1
- package/modules/provisioning/render-standup.js +4 -1
- package/modules/public-landing/public/projects.html +16 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +23 -0
- package/scripts/gds/autobongos-loop.js +37 -1
- package/scripts/gds/autobongos-run.js +38 -80
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +27 -0
- package/tests/autobongos_loop.mjs +107 -94
- package/tests/collab_page.mjs +27 -1
- package/tests/hall_approval_queue.mjs +5 -1
- package/tests/hall_client_request_shape.mjs +103 -0
- package/tests/hall_studio_home.mjs +7 -1
- package/tests/hall_studio_world.mjs +5 -1
- package/tests/help_requests.mjs +54 -1
- package/tests/projects_hub_app_step.mjs +42 -0
- 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.
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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,
|
|
501
|
-
reason:
|
|
502
|
-
sleep_until:
|
|
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,
|
|
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.
|
|
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
|
-
// ---
|
|
839
|
+
// --- usage is judged continuously; hitting the limit sleeps to its reset (task 1004454) ---
|
|
840
840
|
//
|
|
841
|
-
//
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
//
|
|
846
|
-
//
|
|
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
|
-
|
|
854
|
-
const dir = mkdtempSync(join(tmpdir(), 'autobongos-
|
|
855
|
-
|
|
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
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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 () => {
|
|
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(
|
|
901
|
-
assert.
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
876
|
+
const d = {
|
|
877
|
+
state: runner.newRunnerState(),
|
|
915
878
|
readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
|
|
916
|
-
gauge: () =>
|
|
917
|
-
pickTask: async () => {
|
|
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
|
-
|
|
921
|
-
|
|
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('
|
|
925
|
-
const
|
|
926
|
-
|
|
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('
|
|
942
|
-
const
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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) -----------
|
package/tests/collab_page.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|