@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.
@@ -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
- // --- a derived gauge buys BOUNDED work (task 1004178) -----------------------
839
+ // --- usage is judged continuously; hitting the limit sleeps to its reset (task 1004454) ---
699
840
  //
700
- // `derived` retires a reading whose window has reset. That is sound for the
701
- // five-hour window, which turns over during a night. The SEVEN-day one does
702
- // not, so its stale reading is dropped and the weekly pace check — what the
703
- // module's notes call the thing that actually binds a heavy week — does not run
704
- // at all on a derived reading. The task cap is what stands in for it, so the
705
- // cap has to work, and it has to survive a restart: the count comes from the
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
- const derivedLog = () => {
713
- const dir = mkdtempSync(join(tmpdir(), 'autobongos-cap-'));
714
- return join(dir, 'runs.jsonl');
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
- writeFileSync(p, [
722
- JSON.stringify({ at: at(1000), event: 'worked', task_id: '1' }), // before the mark
723
- JSON.stringify({ at: at(3000), event: 'hold' }), // not a worked row
724
- JSON.stringify({ at: at(3100), event: 'worked', task_id: '2' }),
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 () => { throw new Error('CAP BREACHED: reached the picker'); },
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(events[0].event, 'hold');
760
- assert.equal(events[0].derived, true);
761
- assert.equal(events[0].worked_since_derived, 2);
762
- assert.match(events[0].reason, /cap is 2/);
763
- delete process.env.AUTOBONGOS_LOG_FILE; delete process.env.AUTOBONGOS_DERIVED_TASK_CAP;
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
- await runner.iteration({ goals: [], maxTasks: 1 }, {
876
+ const d = {
877
+ state: runner.newRunnerState(),
774
878
  readFence: async () => ({ raw: { enabled: true, goals: [{ goal_id: 7 }] }, graderBypassed: false }),
775
- gauge: () => ({ decision: { go: true, unknown: false, derived: true, derivedSince: 5000, reason: 'derived' } }),
776
- 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'); },
777
881
  record: (row) => { events.push(row); return row; },
778
- });
779
- assert.equal(reachedPicker, true, 'one task done against a cap of two must still proceed');
780
- 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);
781
888
  });
782
889
 
783
- test('countWorkedSince is bounded: a huge log does not mean a huge scan', () => {
784
- const p = derivedLog();
785
- process.env.AUTOBONGOS_LOG_FILE = p;
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('countWorkedSince stops at the first row older than the mark', () => {
801
- const p = derivedLog();
802
- process.env.AUTOBONGOS_LOG_FILE = p;
803
- const at = (s) => new Date(s * 1000).toISOString();
804
- writeFileSync(p, [
805
- JSON.stringify({ at: at(100), event: 'worked', task_id: 'ancient' }),
806
- JSON.stringify({ at: at(5000), event: 'worked', task_id: 'before-mark' }),
807
- JSON.stringify({ at: at(9000), event: 'worked', task_id: 'after-mark' }),
808
- ].join('\n'));
809
- assert.equal(runner.countWorkedSince(8000), 1);
810
- 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));
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
- test('claim.js exits 1 without aborting on a REFUSED claim — measured 3/3 aborting before', async () => {
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, 1, `a refused claim must exit 1, got ${r.status} (127 = the abort); stderr: ${r.stderr}`);
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
  //