@bongos/core 1.20.32 → 1.20.34
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 +39 -29
- package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +19 -5
- package/docs/adr/README.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +1 -1
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/autonomy/cadence.js +17 -0
- package/modules/autonomy/digest.js +5 -2
- package/modules/autonomy/gauge.js +5 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/autobongos-loop.js +37 -1
- package/scripts/gds/autobongos-run.js +189 -120
- package/scripts/gds/claim.js +10 -2
- package/scripts/gds/move-escalation.js +141 -0
- package/scripts/gds/provision.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/autobongos_cadence.mjs +36 -0
- package/tests/autobongos_loop.mjs +249 -95
- package/tests/autonomy_digest.mjs +10 -0
- package/tests/claim_error_surface.mjs +7 -0
- package/tests/cli_exit_no_abort.mjs +7 -2
- package/tests/move_escalation.mjs +191 -0
- package/tests/plain_cards.mjs +2 -2
|
@@ -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
|
|
|
@@ -237,6 +237,12 @@ test('an unreadable claimable feed does not halt everything', async () => {
|
|
|
237
237
|
assert.equal(got.task.id, 9001, 'the not_claimable reason stays quiet when the feed cannot be read');
|
|
238
238
|
});
|
|
239
239
|
|
|
240
|
+
test('pickTask passes over a task the runner has set aside (task 1004396)', async () => {
|
|
241
|
+
const api = fakeApi({ tasks: [ready({ id: 9001 }), ready({ id: 9002 })], claimable: [{ id: 9001 }, { id: 9002 }] });
|
|
242
|
+
const got = await runner.pickTask([1000119], { api, rank: 'archon', exclude: new Set(['9001']) });
|
|
243
|
+
assert.equal(got.task.id, 9002, 'a refused task must not stay at the head of the order');
|
|
244
|
+
});
|
|
245
|
+
|
|
240
246
|
test('a goal whose tasks cannot be read is reported, not silently skipped', async () => {
|
|
241
247
|
const api = fakeApi({ tasks: [], claimable: [] });
|
|
242
248
|
api.tasks.getTasks = async () => { throw new Error('boom'); };
|
|
@@ -273,7 +279,11 @@ function harness(over = {}) {
|
|
|
273
279
|
// The LEDGER, faked. iteration() now reads the task back from Bongos after
|
|
274
280
|
// every worker (task 1003903) instead of believing the worker's own verdict,
|
|
275
281
|
// so a test that does not say what the ledger holds is not describing a run.
|
|
276
|
-
api: {},
|
|
282
|
+
api: over.api || {},
|
|
283
|
+
// Per-test runner memory (task 1004396): refused tasks and a queue gate
|
|
284
|
+
// outlive one iteration, so a shared module-level copy would leak between tests.
|
|
285
|
+
state: over.state || runner.newRunnerState(),
|
|
286
|
+
now: over.now,
|
|
277
287
|
verifyDeps: {
|
|
278
288
|
task: over.ledger !== undefined ? over.ledger : { id: 4242, status: 'shipped', updated_at: new Date().toISOString() },
|
|
279
289
|
probeArtifact: over.probeArtifact || (async () => ({ checked: true, onMain: true, sha: 'a'.repeat(40), subject: 'x (task 4242)' })),
|
|
@@ -422,6 +432,137 @@ test('failureReason: stdout is used when stderr is empty, whitespace is collapse
|
|
|
422
432
|
assert.equal(runner.failureReason({ code: 1, stderr: 'a'.repeat(400) }, 'x').length, 400);
|
|
423
433
|
});
|
|
424
434
|
|
|
435
|
+
// ── task 1004396: a refusal is not a reason to stop, unless it refuses everything ──
|
|
436
|
+
//
|
|
437
|
+
// Owner ruling (2026-09-30): a refused claim must not put the runner into waiting
|
|
438
|
+
// by default. It sets that task aside and tries the next one. It waits only when
|
|
439
|
+
// there is a block AND nothing else it can claim — which is exactly the shape of
|
|
440
|
+
// the queue-wide gate (a stranded confirmed task refuses EVERY claim).
|
|
441
|
+
|
|
442
|
+
const taskA = { ...aTask, id: 4242 };
|
|
443
|
+
const taskB = { ...aTask, id: 4343, title: 'The next thing' };
|
|
444
|
+
// A picker that honours the exclusion set, the way the real pickTask now does.
|
|
445
|
+
const pickAorB = async (_goals, pd = {}) => {
|
|
446
|
+
const ex = pd.exclude || new Set();
|
|
447
|
+
for (const t of [taskA, taskB]) if (!ex.has(String(t.id))) return { task: t, goalId: 1000119, skipped: [] };
|
|
448
|
+
return { none: true, skipped: [] };
|
|
449
|
+
};
|
|
450
|
+
const scriptOf = (args) => String(args[0]).split(/[\\/]/).pop();
|
|
451
|
+
const gatedCard = [
|
|
452
|
+
'Your queue is gated — 1 confirmed task(s) are waiting on your rebase:',
|
|
453
|
+
' - task 777 A stranded thing',
|
|
454
|
+
' flagged: strand:branch_modifies_executed_code',
|
|
455
|
+
].join(String.fromCharCode(10));
|
|
456
|
+
|
|
457
|
+
test('a task refused on its own is set aside and the NEXT task is worked in the same pass', async () => {
|
|
458
|
+
const claims = [];
|
|
459
|
+
const h = harness({
|
|
460
|
+
pickTask: pickAorB,
|
|
461
|
+
ledger: { id: 4343, status: 'shipped', updated_at: new Date().toISOString() },
|
|
462
|
+
run: async (bin, args) => {
|
|
463
|
+
if (scriptOf(args) === 'claim.js') {
|
|
464
|
+
claims.push(args[1]);
|
|
465
|
+
return args[1] === '4242' ? { ok: false, code: 1, stdout: '', stderr: 'DEPS_NOT_SHIPPED' } : { ok: true, code: 0, stdout: '', stderr: '' };
|
|
466
|
+
}
|
|
467
|
+
return { ok: true, code: 0, stdout: '', stderr: '' };
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
const row = await runner.iteration(opts, h.deps);
|
|
471
|
+
assert.deepEqual(claims, ['4242', '4343'], 'the refusal must not end the pass');
|
|
472
|
+
assert.equal(row.event, 'worked');
|
|
473
|
+
assert.equal(row.task_id, 4343);
|
|
474
|
+
const refused = h.rows.find((r) => r.event === 'claim_failed');
|
|
475
|
+
assert.equal(refused.task_id, 4242, 'the refusal is still logged, with its reason');
|
|
476
|
+
assert.ok(refused.set_aside_s > 0, 'and says the task is set aside');
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('a set-aside task is not re-tried on the next wake, and comes back once the set-aside expires', async () => {
|
|
480
|
+
let t = 1_000_000;
|
|
481
|
+
const state = runner.newRunnerState();
|
|
482
|
+
const claims = [];
|
|
483
|
+
const mk = () => harness({
|
|
484
|
+
state, now: () => t, pickTask: pickAorB,
|
|
485
|
+
run: async (bin, args) => {
|
|
486
|
+
if (scriptOf(args) === 'claim.js') { claims.push(args[1]); return { ok: false, code: 1, stdout: '', stderr: 'nope' }; }
|
|
487
|
+
return { ok: true, code: 0, stdout: '', stderr: '' };
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
await runner.iteration(opts, mk().deps);
|
|
491
|
+
assert.deepEqual(claims, ['4242', '4343']);
|
|
492
|
+
const second = await runner.iteration(opts, mk().deps);
|
|
493
|
+
assert.deepEqual(claims, ['4242', '4343'], 'no claim is re-attempted while both are set aside');
|
|
494
|
+
assert.equal(second.event, 'nothing_claimable');
|
|
495
|
+
assert.deepEqual(second.set_aside.map((s) => s.id).sort(), ['4242', '4343']);
|
|
496
|
+
t += runner.CLAIM_SET_ASIDE_MS + 1;
|
|
497
|
+
await runner.iteration(opts, mk().deps);
|
|
498
|
+
assert.deepEqual(claims.slice(2), ['4242', '4343'], 'after the set-aside they are tried again');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('when EVERY candidate is refused, the last failure is returned so a real outage still escalates', async () => {
|
|
502
|
+
const h = harness({
|
|
503
|
+
pickTask: pickAorB,
|
|
504
|
+
run: async (bin, args) => (scriptOf(args) === 'claim.js'
|
|
505
|
+
? { ok: false, code: 1, stdout: '', stderr: 'fetch failed' }
|
|
506
|
+
: { ok: true, code: 0, stdout: '', stderr: '' }),
|
|
507
|
+
});
|
|
508
|
+
const row = await runner.iteration(opts, h.deps);
|
|
509
|
+
assert.equal(row.event, 'claim_failed', 'returning "nothing claimable" here would hide a network outage as idle');
|
|
510
|
+
assert.equal(h.rows.filter((r) => r.event === 'claim_failed').length, 2);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('a queue-wide gate WAITS, names the stranded task, and makes no claim until it lands', async () => {
|
|
514
|
+
const state = runner.newRunnerState();
|
|
515
|
+
let status = 'confirmed';
|
|
516
|
+
let gated = true;
|
|
517
|
+
const claims = [];
|
|
518
|
+
const api = { tasks: { getTasksId: async ({ id }) => ({ ok: true, data: { task: { id, status } } }) } };
|
|
519
|
+
const mk = () => harness({
|
|
520
|
+
state, api, pickTask: pickAorB,
|
|
521
|
+
run: async (bin, args) => {
|
|
522
|
+
if (scriptOf(args) === 'claim.js') {
|
|
523
|
+
claims.push(args[1]);
|
|
524
|
+
return gated ? { ok: false, code: runner.QUEUE_GATED_EXIT, stdout: '', stderr: gatedCard } : { ok: true, code: 0, stdout: '', stderr: '' };
|
|
525
|
+
}
|
|
526
|
+
return { ok: true, code: 0, stdout: '', stderr: '' };
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const first = await runner.iteration(opts, mk().deps);
|
|
531
|
+
assert.equal(first.event, 'queue_gated');
|
|
532
|
+
assert.deepEqual(first.waiting_on, ['777'], 'the wait names what it is waiting on');
|
|
533
|
+
assert.deepEqual(claims, ['4242'], 'a gate refuses every claim, so trying the next task is pointless');
|
|
534
|
+
|
|
535
|
+
const second = await runner.iteration(opts, mk().deps);
|
|
536
|
+
assert.equal(second.event, 'queue_gated');
|
|
537
|
+
assert.deepEqual(claims, ['4242'], 'no claim is attempted while the strand is still confirmed');
|
|
538
|
+
|
|
539
|
+
status = 'shipped'; gated = false;
|
|
540
|
+
const third = await runner.iteration(opts, mk().deps);
|
|
541
|
+
assert.equal(third.event, 'worked', 'once the strand lands the runner resumes by itself');
|
|
542
|
+
assert.deepEqual(claims, ['4242', '4242']);
|
|
543
|
+
assert.equal(state.queueGate, null, 'the gate is forgotten');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('a gate whose stranded task cannot be read back keeps waiting rather than guessing', async () => {
|
|
547
|
+
const state = runner.newRunnerState();
|
|
548
|
+
state.queueGate = { owed: ['777'], since: Date.now(), reason: 'x' };
|
|
549
|
+
let claimed = false;
|
|
550
|
+
const h = harness({
|
|
551
|
+
state, pickTask: pickAorB,
|
|
552
|
+
api: { tasks: { getTasksId: async () => ({ ok: false, status: 502 }) } },
|
|
553
|
+
run: async (bin, args) => { if (scriptOf(args) === 'claim.js') claimed = true; return { ok: true, code: 0, stdout: '', stderr: '' }; },
|
|
554
|
+
});
|
|
555
|
+
const row = await runner.iteration(opts, h.deps);
|
|
556
|
+
assert.equal(row.event, 'queue_gated');
|
|
557
|
+
assert.equal(claimed, false);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('the runner and claim.js agree on the queue-gated exit code', () => {
|
|
561
|
+
const claim = require('../scripts/gds/claim.js');
|
|
562
|
+
assert.equal(runner.QUEUE_GATED_EXIT, claim.QUEUE_GATED_EXIT);
|
|
563
|
+
assert.notEqual(runner.QUEUE_GATED_EXIT, 1, 'it must differ from the generic refusal, or the runner cannot tell them apart');
|
|
564
|
+
});
|
|
565
|
+
|
|
425
566
|
test('a worker that cannot finish frees the claim; one that really shipped does not', async () => {
|
|
426
567
|
// Which of these happens is now decided by the LEDGER, not by the worker's own
|
|
427
568
|
// verdict: a stuck worker leaves the task still `active`, a finished one leaves
|
|
@@ -695,119 +836,132 @@ test('workerEnv passes Claude auth through, and still refuses the rest', () => {
|
|
|
695
836
|
assert.equal(env[leak], undefined, `${leak} must never reach a bypassPermissions worker`);
|
|
696
837
|
}
|
|
697
838
|
});
|
|
698
|
-
// ---
|
|
839
|
+
// --- usage is judged continuously; hitting the limit sleeps to its reset (task 1004454) ---
|
|
699
840
|
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
// 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.
|
|
707
847
|
|
|
708
848
|
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
|
|
709
849
|
import { tmpdir } from 'node:os';
|
|
710
850
|
import { join } from 'node:path';
|
|
711
851
|
|
|
712
|
-
|
|
713
|
-
const dir = mkdtempSync(join(tmpdir(), 'autobongos-
|
|
714
|
-
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
test('countWorkedSince counts only worked rows at or after the mark', () => {
|
|
718
|
-
const p = derivedLog();
|
|
719
|
-
process.env.AUTOBONGOS_LOG_FILE = p;
|
|
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');
|
|
720
855
|
const at = (s) => new Date(s * 1000).toISOString();
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
'{ this line is torn', // costs one row, not the count
|
|
726
|
-
JSON.stringify({ at: at(3200), event: 'worked', task_id: '3' }),
|
|
727
|
-
].join('\n'));
|
|
728
|
-
assert.equal(runner.countWorkedSince(2000), 2);
|
|
729
|
-
assert.equal(runner.countWorkedSince(0), 0, 'a zero/absent mark counts nothing rather than everything');
|
|
730
|
-
delete process.env.AUTOBONGOS_LOG_FILE;
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test('countWorkedSince treats an unreadable log as zero, not as a refusal', () => {
|
|
734
|
-
process.env.AUTOBONGOS_LOG_FILE = join(tmpdir(), 'autobongos-cap-nope', 'missing.jsonl');
|
|
735
|
-
assert.equal(runner.countWorkedSince(1), 0,
|
|
736
|
-
'the fence and the gauge are the gates; a bound that bricks the runner when its own log is gone is worse');
|
|
737
|
-
delete process.env.AUTOBONGOS_LOG_FILE;
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
test('a derived gauge HOLDS once the cap is reached, and the reason says why', async () => {
|
|
741
|
-
const p = derivedLog();
|
|
742
|
-
process.env.AUTOBONGOS_LOG_FILE = p;
|
|
743
|
-
process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
|
|
744
|
-
const at = (s) => new Date(s * 1000).toISOString();
|
|
745
|
-
writeFileSync(p, [
|
|
746
|
-
JSON.stringify({ at: at(5100), event: 'worked', task_id: '1' }),
|
|
747
|
-
JSON.stringify({ at: at(5200), event: 'worked', task_id: '2' }),
|
|
748
|
-
].join('\n'));
|
|
749
|
-
|
|
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;
|
|
750
860
|
const events = [];
|
|
751
861
|
await runner.iteration({ goals: [], maxTasks: 1 }, {
|
|
862
|
+
state: runner.newRunnerState(),
|
|
752
863
|
readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
|
|
753
864
|
gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
|
|
754
|
-
pickTask: async () => {
|
|
865
|
+
pickTask: async () => { reachedPicker = true; return { none: true, skipped: [] }; },
|
|
755
866
|
record: (row) => { events.push(row); return row; },
|
|
756
|
-
run: async () => { throw new Error('CAP BREACHED: ran a command'); },
|
|
757
|
-
spawnWorker: async () => { throw new Error('CAP BREACHED: spawned a worker'); },
|
|
758
867
|
});
|
|
759
|
-
assert.equal(
|
|
760
|
-
assert.
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
test('a derived gauge still WORKS while under the cap', async () => {
|
|
767
|
-
const p = derivedLog();
|
|
768
|
-
process.env.AUTOBONGOS_LOG_FILE = p;
|
|
769
|
-
process.env.AUTOBONGOS_DERIVED_TASK_CAP = '2';
|
|
770
|
-
writeFileSync(p, JSON.stringify({ at: new Date(5100 * 1000).toISOString(), event: 'worked', task_id: '1' }));
|
|
771
|
-
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;
|
|
772
875
|
const events = [];
|
|
773
|
-
|
|
876
|
+
const d = {
|
|
877
|
+
state: runner.newRunnerState(),
|
|
774
878
|
readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
|
|
775
|
-
gauge: () =>
|
|
776
|
-
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'); },
|
|
777
881
|
record: (row) => { events.push(row); return row; },
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
|
|
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);
|
|
781
888
|
});
|
|
782
889
|
|
|
783
|
-
test('
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
const at = (s) => new Date(s * 1000).toISOString();
|
|
787
|
-
// 40k old rows, then the two that matter. A whole-file parse would touch
|
|
788
|
-
// every one of them on EVERY iteration of a loop that never exits.
|
|
789
|
-
const old = [];
|
|
790
|
-
for (let i = 0; i < 40000; i++) old.push(JSON.stringify({ at: at(1000 + i), event: 'worked', task_id: String(i) }));
|
|
791
|
-
old.push(JSON.stringify({ at: at(900000), event: 'worked', task_id: 'recent-1' }));
|
|
792
|
-
old.push(JSON.stringify({ at: at(900100), event: 'worked', task_id: 'recent-2' }));
|
|
793
|
-
writeFileSync(p, old.join('\n'));
|
|
794
|
-
const t0 = Date.now();
|
|
795
|
-
assert.equal(runner.countWorkedSince(800000), 2, 'only the rows at or after the mark count');
|
|
796
|
-
assert.ok(Date.now() - t0 < 500, 'and it must not walk the whole log to say so');
|
|
797
|
-
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 });
|
|
798
893
|
});
|
|
799
894
|
|
|
800
|
-
test('
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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));
|
|
811
965
|
});
|
|
812
966
|
|
|
813
967
|
// --- a --forever runner must be able to take a fix (task 1004374) -----------
|
|
@@ -97,6 +97,16 @@ test('shipped is the LEDGER’s answer, never the worker’s', () => {
|
|
|
97
97
|
assert.deepEqual(d.onHold.ranButDidNotShip.map((r) => r.shape), ['still_held', 'shipped_no_artifact']);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
test('a queue-gate wait is a hold that names the stranded task, not an unrecognised event (task 1004396)', () => {
|
|
101
|
+
const d = buildDigest([
|
|
102
|
+
{ at: at(0), event: 'queue_gated', waiting_on: ['777'], reason: 'every claim is refused until task 777 lands — waiting, not claiming (x)' },
|
|
103
|
+
{ at: at(5), event: 'queue_gate_cleared', waited_on: ['777'], waited_s: 300 },
|
|
104
|
+
], { nowEpochS: NOW_S });
|
|
105
|
+
assert.equal(d.runner.unrecognised, 0);
|
|
106
|
+
assert.equal(d.onHold.held.length, 1);
|
|
107
|
+
assert.match(d.onHold.held[0].reason, /task 777/);
|
|
108
|
+
});
|
|
109
|
+
|
|
100
110
|
test('an unworkable queue is reported as a queue problem, not as breakage', () => {
|
|
101
111
|
const d = buildDigest([{ at: at(0), event: 'nothing_claimable', skipped: [{ id: 9, reasons: ['needs_migration'] }] }], { nowEpochS: NOW_S });
|
|
102
112
|
assert.equal(d.onHold.unworkable.length, 1);
|
|
@@ -231,6 +231,13 @@ t('REBASE_REQUIRED: names the owed tasks and relays the server hint', () => {
|
|
|
231
231
|
'must not fall through to the bare code echo');
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
t('REBASE_REQUIRED exits with its OWN code, so an unattended caller can tell a queue gate from a task refusal (task 1004396)', () => {
|
|
235
|
+
const { QUEUE_GATED_EXIT } = require('../scripts/gds/claim.js');
|
|
236
|
+
assert.equal(formatClaimFailure(envelope('REBASE_REQUIRED', {}), 5).exit, QUEUE_GATED_EXIT);
|
|
237
|
+
assert.notEqual(QUEUE_GATED_EXIT, 1);
|
|
238
|
+
assert.equal(formatClaimFailure(envelope('ALREADY_CLAIMED', {}), 5).exit, 1, 'a per-task refusal keeps exit 1');
|
|
239
|
+
});
|
|
240
|
+
|
|
234
241
|
t('REBASE_REQUIRED: still guides when the server sends no hint and no task list', () => {
|
|
235
242
|
const out = text(formatClaimFailure(envelope('REBASE_REQUIRED', {}), 5));
|
|
236
243
|
assert.match(out, /gated/i, 'must say the queue is gated');
|
|
@@ -348,7 +348,12 @@ test('land-watch.js exits 0 after a REAL request — the teardown that aborted 3
|
|
|
348
348
|
// throwaway session, so no real API is touched and the developer's own config is
|
|
349
349
|
// never read or written. `--here` because CI may check out the MAIN checkout,
|
|
350
350
|
// where the worktree guard would refuse at exit 2 before any request is made.
|
|
351
|
-
|
|
351
|
+
// The stub refuses with REBASE_REQUIRED, the queue-wide gate, which exits with its
|
|
352
|
+
// own code since task 1004396 (QUEUE_GATED_EXIT) so an unattended caller can tell a
|
|
353
|
+
// gate from a task refusal. The abort this guards against shows as 127 either way.
|
|
354
|
+
const { QUEUE_GATED_EXIT } = createRequire(import.meta.url)('../scripts/gds/claim.js');
|
|
355
|
+
|
|
356
|
+
test('claim.js exits with its refusal code without aborting on a REFUSED claim — measured 3/3 aborting before', async () => {
|
|
352
357
|
const { proc, port } = await startStub({ claimStatus: 409 });
|
|
353
358
|
try {
|
|
354
359
|
const home = makeHome({ apiBase: `http://127.0.0.1:${port}` });
|
|
@@ -361,7 +366,7 @@ test('claim.js exits 1 without aborting on a REFUSED claim — measured 3/3 abor
|
|
|
361
366
|
});
|
|
362
367
|
assert.notEqual(r.signal, 'SIGTERM', `run ${i + 1} timed out — draining left a handle armed`);
|
|
363
368
|
assert.doesNotMatch(r.stderr || '', ABORT_RE, `run ${i + 1} aborted natively:\n${r.stderr}`);
|
|
364
|
-
assert.equal(r.status,
|
|
369
|
+
assert.equal(r.status, QUEUE_GATED_EXIT, `a queue-gated claim must exit ${QUEUE_GATED_EXIT}, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
|
|
365
370
|
// End-to-end proof that the new REBASE_REQUIRED rendering reaches a terminal,
|
|
366
371
|
// not just the unit test: the stub refuses with that exact envelope.
|
|
367
372
|
//
|