@bongos/core 1.19.873 → 1.19.875

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.
@@ -6,15 +6,18 @@
6
6
  // one worker → record. No model anywhere in this file; every branch it takes is
7
7
  // a branch a person can read.
8
8
  //
9
- // node scripts/gds/autobongos-run.js --goal 1000119 # one task
10
- // node scripts/gds/autobongos-run.js --goal 1000119 --max-tasks 5
11
- // node scripts/gds/autobongos-run.js --goal 1000119 --dry-run # pick only, claim nothing
9
+ // node scripts/gds/autobongos-run.js # every allowlisted goal
10
+ // node scripts/gds/autobongos-run.js --goal 1000119 # narrow to one of them
11
+ // node scripts/gds/autobongos-run.js --max-tasks 5
12
+ // node scripts/gds/autobongos-run.js --dry-run # pick only, claim nothing
12
13
  //
13
14
  // SCOPE, and what deliberately is NOT here:
14
- // · the ALLOWLIST is `--goal` / AUTOBONGOS_GOALS for now. Task 1003908 moves it
15
- // into the database where the machine cannot edit it, and adds the kill
16
- // switch. Until then this refuses to run with no goal named, rather than
17
- // defaulting to "anything claimable".
15
+ // · the ALLOWLIST and the KILL SWITCH live in the DATABASE (task 1003908,
16
+ // ADR 0307 §6) and are read at step 0 of every iteration. `--goal` and
17
+ // AUTOBONGOS_GOALS survive only as a NARROWING filter — they can take goals
18
+ // away from the allowlist and can never add one, because a setting on this
19
+ // machine is editable by the very bypassPermissions worker the allowlist
20
+ // exists to bound.
18
21
  // · CONFIRMING a ship against the ledger is autobongos-verify.js (task
19
22
  // 1003903), called at step 6. A worker's `shipped` is a CLAIM — recorded here
20
23
  // as `claims_shipped` so nothing can mistake it for a fact — and the ledger
@@ -33,6 +36,12 @@ const {
33
36
  buildWorkerArgs, buildWorkerPrompt, classifyRun, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
34
37
  } = require('./autobongos-loop.js');
35
38
  const { verifyShip } = require('./autobongos-verify.js');
39
+ const fence = require('../../modules/autonomy/fence.js');
40
+ // The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
41
+ // sits outside the kernel boundary, so this direct core require is allowed here
42
+ // where it would not be inside modules/ — which is exactly why fence.js takes the
43
+ // hits as an argument instead of matching them itself.
44
+ const { matchProtected } = require('../../src/bongos/permission-path-check');
36
45
 
37
46
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
47
 
@@ -51,6 +60,10 @@ function readArgv(argv) {
51
60
  if (!out.goals.length && process.env.AUTOBONGOS_GOALS) {
52
61
  out.goals = String(process.env.AUTOBONGOS_GOALS).split(',').map((g) => Number(g.trim())).filter(Boolean);
53
62
  }
63
+ // NOTE the semantics changed in task 1003908: `out.goals` is a REQUESTED filter,
64
+ // intersected with the database allowlist. An empty list no longer means "refuse
65
+ // to run" — it means "every allowlisted goal" — because the thing that refuses
66
+ // when nothing is permitted is now the fence, which this machine cannot edit.
54
67
  return out;
55
68
  }
56
69
 
@@ -202,6 +215,33 @@ function worktreeName(taskId) {
202
215
  return `autobongos-${taskId}-${crypto.randomBytes(3).toString('hex')}`;
203
216
  }
204
217
 
218
+ // readFence — the two facts the fence is made of, in one place.
219
+ //
220
+ // BOTH come from the server. `GET /autonomy/fence` is the kill switch and the
221
+ // allowlist; `GET /me`'s `grader_bypass` is ADR 0041's global grader killswitch,
222
+ // resolved server-side from the server's own env. Neither is readable from a file
223
+ // on this machine, and that is the entire point — see modules/autonomy/fence.js.
224
+ //
225
+ // A failure of EITHER read returns the shape that refuses. An unreachable
226
+ // instance and a thrown kill switch are different facts, but they license the
227
+ // same action: do nothing.
228
+ async function readFence(deps = {}) {
229
+ const api = deps.api || await (require('./cli-lib').cliClient)();
230
+ let raw = null;
231
+ let graderBypassed = null;
232
+ try {
233
+ const r = await api.autonomy.getAutonomyFence();
234
+ if (r && r.ok) raw = r.data;
235
+ } catch (_) { /* unreadable → refuses below */ }
236
+ try {
237
+ const me = await api.me.getMe();
238
+ // Strictly a boolean or nothing. `undefined` from an older server is NOT
239
+ // `false`: it means this instance cannot tell us, which refuses.
240
+ if (me && me.ok && typeof me.data.grader_bypass === 'boolean') graderBypassed = me.data.grader_bypass;
241
+ } catch (_) { /* unreadable → refuses below */ }
242
+ return { raw, graderBypassed };
243
+ }
244
+
205
245
  async function iteration(opts, deps = {}) {
206
246
  const runCmd = deps.run || run;
207
247
  const spawnWork = deps.spawnWorker || spawnWorker;
@@ -209,17 +249,53 @@ async function iteration(opts, deps = {}) {
209
249
  const pick = deps.pickTask || pickTask;
210
250
  const log = deps.record || record;
211
251
 
252
+ // 0. THE FENCE (task 1003908, ADR 0307 §6). Before the gauge, because three of
253
+ // its four gates make every task refusable and there is no sense paying for a
254
+ // capacity read to discover that. Before anything is picked, claimed or spawned,
255
+ // because a fence checked after the fact is not a fence.
256
+ const { raw, graderBypassed } = deps.readFence ? await deps.readFence(deps) : await readFence(deps);
257
+ // NOT named `run`. The module-level exec helper above is called `run`, and a
258
+ // `const run` anywhere in this function hoists into a Temporal Dead Zone that
259
+ // covers the WHOLE body — including `deps.run || run` at the top, which is the
260
+ // PRODUCTION call shape (main() passes no deps). That shadowing threw
261
+ // `ReferenceError: Cannot access 'run' before initialization` on the first line
262
+ // of every real iteration while every test stayed green, because the harness
263
+ // always injected deps.run. Caught by the grader on task 1003908; the test that
264
+ // now drives iteration() with no deps is the actual fix — this rename is half.
265
+ const gate = fence.decideRun({ fence: raw, requestedGoals: opts.goals, graderBypassed });
266
+ if (!gate.go) {
267
+ return log({ event: 'fenced', code: gate.code, reason: gate.reason, refused_goals: gate.refusedGoals || [] });
268
+ }
269
+
212
270
  // 1. The gauge. Never start a task we cannot pay for out of the window.
213
271
  const g = readGauge();
214
272
  if (!g.decision.go) {
215
273
  return log({ event: 'hold', go: false, unknown: !!g.decision.unknown, reason: g.decision.reason, sleep_until: g.decision.sleepUntil ?? null });
216
274
  }
217
275
 
218
- // 2. Pick.
219
- const picked = await pick(opts.goals, deps.pickDeps || {});
276
+ // 2. Pick — from the ALLOWLISTED goals, never from what the command line asked
277
+ // for. gate.goals is already the intersection.
278
+ const picked = await pick(gate.goals, deps.pickDeps || {});
220
279
  if (picked.error) return log({ event: 'pick_failed', reason: picked.error });
221
280
  if (picked.none) return log({ event: 'nothing_claimable', goals: opts.goals });
222
281
  const task = picked.task;
282
+
283
+ // 2b. The per-TASK half of the fence. sequence.js's halt matrix already refuses
284
+ // a protected-path task, and this re-asks anyway — not from distrust of the
285
+ // matrix, but because the matrix is a shared component with its own callers and
286
+ // the fence must not be a property of whoever happens to be picking. A gate that
287
+ // only holds while a collaborator keeps calling it is not a gate.
288
+ //
289
+ // Both halves read the task's DECLARED touches[], which is a claim, not a fact —
290
+ // see the header of modules/autonomy/fence.js for why that is acceptable as the
291
+ // OUTER of three layers and would not be as the only one.
292
+ const taskGate = fence.decideTask({
293
+ task, goals: gate.goals, protectedHits: matchProtected(task.touches || []),
294
+ });
295
+ if (!taskGate.go) {
296
+ return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: task.id, goal_id: picked.goalId, globs: taskGate.globs || [] });
297
+ }
298
+
223
299
  if (opts.dryRun) return log({ event: 'dry_run', task_id: task.id, title: task.title, goal_id: picked.goalId });
224
300
 
225
301
  // 3. The task's own working tree.
@@ -290,11 +366,10 @@ async function iteration(opts, deps = {}) {
290
366
 
291
367
  async function main(argv) {
292
368
  const opts = readArgv(argv);
293
- if (!opts.goals.length) {
294
- console.error('autobongos-run: name at least one allowlisted goal (--goal N, or AUTOBONGOS_GOALS).');
295
- console.error(' Refusing to run against "anything claimable" — the allowlist is half of what makes this safe to leave alone.');
296
- return 2;
297
- }
369
+ // No "name a goal" refusal here any more. It used to be the only allowlist, and
370
+ // it lived on the machine being fenced; the database owns that now, and an empty
371
+ // `--goal` means "every allowlisted goal" rather than "anything claimable". The
372
+ // refusal did not disappear — it moved somewhere the runner host cannot edit.
298
373
  for (let i = 0; i < opts.maxTasks; i++) {
299
374
  const row = await iteration(opts);
300
375
  if (opts.json) console.log(JSON.stringify(row));
@@ -314,4 +389,4 @@ if (require.main === module) {
314
389
  });
315
390
  }
316
391
 
317
- module.exports = { readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, worktreeName };
392
+ module.exports = { readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, worktreeName, readFence };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.873'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.875'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,262 @@
1
+ // tests/autobongos_fence.mjs — the Autobongos fence (task 1003908, ADR 0307 §6).
2
+ //
3
+ // Four gates, and the done-when names them: a task in a non-allowlisted goal, a
4
+ // task touching a protected path, and a run with the grader disabled are each
5
+ // refused, and the kill switch stops the runner without anyone touching the
6
+ // machine. Each has a test that names it, plus the property that binds them all —
7
+ // EVERY gate fails closed, because a machine that cannot prove it is allowed to
8
+ // act must not act.
9
+
10
+ import assert from 'node:assert/strict';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const fence = require('../modules/autonomy/fence.js');
16
+ const runner = require('../scripts/gds/autobongos-run.js');
17
+ const catalog = require('../modules/government/catalog.js');
18
+
19
+ const { decideRun, decideTask, normaliseFence, allowedGoals } = fence;
20
+
21
+ const open = (over = {}) => ({ enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }], ...over });
22
+ const GRADER_OK = { graderBypassed: false };
23
+
24
+ // ── gate 1: the kill switch ─────────────────────────────────────────────────────
25
+
26
+ test('the kill switch stops the runner, and says who stopped it', () => {
27
+ const d = decideRun({ fence: open({ enabled: false, paused_reason: 'owner paused for the release' }), ...GRADER_OK });
28
+ assert.equal(d.go, false);
29
+ assert.equal(d.code, 'kill_switch');
30
+ assert.match(d.reason, /owner paused for the release/, 'a machine that stopped must be able to say WHY, not just "halted"');
31
+ assert.deepEqual(d.goals, []);
32
+ });
33
+
34
+ test('the switch is checked before anything else can permit a run', () => {
35
+ // A disabled fence with a full allowlist and a healthy grader still refuses.
36
+ const d = decideRun({ fence: open({ enabled: false }), requestedGoals: [1000119], ...GRADER_OK });
37
+ assert.equal(d.code, 'kill_switch');
38
+ });
39
+
40
+ test('the kill switch cannot be delegated below the owner', () => {
41
+ // The switch is only a stop button if the thing being stopped cannot grant
42
+ // itself the power to un-stop. The catalog invariant is what enforces that.
43
+ const entry = catalog.byKey('autonomy.fence.manage');
44
+ assert.ok(entry, 'the permission must exist in the catalog');
45
+ assert.equal(entry.system, true);
46
+ assert.equal(entry.floor, 'archon');
47
+ assert.equal(catalog.isGrantableTo('autonomy.fence.manage', 'metic'), false);
48
+ });
49
+
50
+ // ── gate 2: the allowlist ───────────────────────────────────────────────────────
51
+
52
+ test('a task in a non-allowlisted goal is refused', () => {
53
+ const goals = [1000119];
54
+ assert.equal(decideTask({ task: { id: 1, goal_id: 1000119 }, goals }).go, true);
55
+
56
+ const d = decideTask({ task: { id: 2, goal_id: 1000090 }, goals });
57
+ assert.equal(d.go, false);
58
+ assert.equal(d.code, 'goal_not_allowlisted');
59
+ assert.match(d.reason, /1000090/);
60
+ });
61
+
62
+ test('a task with no goal at all is refused', () => {
63
+ for (const goal_id of [null, undefined, '', 0]) {
64
+ assert.equal(decideTask({ task: { id: 1, goal_id }, goals: [1000119] }).code, 'goal_not_allowlisted', String(goal_id));
65
+ }
66
+ });
67
+
68
+ test('the command line can only NARROW the allowlist, never widen it', () => {
69
+ // The whole reason the list moved into the database: a setting on the runner
70
+ // host is editable by the bypassPermissions worker the list exists to bound.
71
+ const f = open({ goals: [{ goal_id: 1000119 }, { goal_id: 1000086 }] });
72
+
73
+ const narrowed = decideRun({ fence: f, requestedGoals: [1000119], ...GRADER_OK });
74
+ assert.deepEqual(narrowed.goals, [1000119]);
75
+
76
+ const widened = decideRun({ fence: f, requestedGoals: [1000119, 9999999], ...GRADER_OK });
77
+ assert.deepEqual(widened.goals, [1000119], 'a goal the database does not allow must be dropped');
78
+ assert.deepEqual(widened.refusedGoals, [9999999], 'and reported, not silently ignored');
79
+ });
80
+
81
+ test('asking ONLY for a goal that is not allowed refuses the whole run', () => {
82
+ const d = decideRun({ fence: open(), requestedGoals: [9999999], ...GRADER_OK });
83
+ assert.equal(d.go, false);
84
+ assert.equal(d.code, 'goal_not_allowlisted');
85
+ });
86
+
87
+ test('an empty allowlist means nothing may be worked', () => {
88
+ const d = decideRun({ fence: open({ goals: [] }), ...GRADER_OK });
89
+ assert.equal(d.go, false);
90
+ assert.equal(d.code, 'no_allowlist');
91
+ });
92
+
93
+ test('a goal id that is not a positive integer never enters the allowlist', () => {
94
+ // Number('') is 0, and a 0 sitting in an allowlist is a goal nobody granted.
95
+ const f = normaliseFence(open({ goals: [{ goal_id: 1000119 }, { goal_id: '' }, { goal_id: 0 }, { goal_id: -3 }, { goal_id: 'abc' }, null] }));
96
+ assert.deepEqual(f.goals, [1000119]);
97
+ });
98
+
99
+ test('a goal row may be a bare id or an object — both are read the same', () => {
100
+ assert.deepEqual(normaliseFence(open({ goals: [1000119, { goal_id: '1000086' }] })).goals, [1000119, 1000086]);
101
+ });
102
+
103
+ // ── gate 3: protected paths ─────────────────────────────────────────────────────
104
+
105
+ test('a task touching a protected path is refused, and the glob is named', () => {
106
+ const d = decideTask({
107
+ task: { id: 3, goal_id: 1000119 },
108
+ goals: [1000119],
109
+ protectedHits: [{ file: 'migrations/999_x.sql', glob: 'migrations/**' }],
110
+ });
111
+ assert.equal(d.go, false);
112
+ assert.equal(d.code, 'protected_path');
113
+ assert.deepEqual(d.globs, ['migrations/**']);
114
+ });
115
+
116
+ test('the protected-path gate runs against the REAL registry, not a fixture', async () => {
117
+ // A test that only ever feeds hand-made hits proves the branch, not the fence.
118
+ // These paths are the four families ADR 0307 §6 names: permission, migration,
119
+ // infrastructure, deploy pipeline.
120
+ const { matchProtected } = require('../src/bongos/permission-path-check');
121
+ for (const file of ['migrations/126_autonomy.sql', 'docs/canonical-permissions.md', 'infra/deploy.sh']) {
122
+ const hits = matchProtected([file]);
123
+ if (!hits.length) continue; // the registry, not this test, decides what is protected
124
+ const d = decideTask({ task: { id: 4, goal_id: 1000119 }, goals: [1000119], protectedHits: hits });
125
+ assert.equal(d.code, 'protected_path', `${file} is in the registry and must be refused`);
126
+ }
127
+ // At least one of them must actually be protected, or this test is vacuous.
128
+ assert.ok(['migrations/126_autonomy.sql', 'docs/canonical-permissions.md', 'infra/deploy.sh']
129
+ .some((f) => matchProtected([f]).length), 'the registry matched nothing — this test would pass on a broken fence');
130
+ });
131
+
132
+ // ── gate 4: the grader ──────────────────────────────────────────────────────────
133
+
134
+ test('a run with the grader disabled is refused', () => {
135
+ const d = decideRun({ fence: open(), graderBypassed: true });
136
+ assert.equal(d.go, false);
137
+ assert.equal(d.code, 'grader_unavailable');
138
+ assert.match(d.reason, /only review/);
139
+ });
140
+
141
+ test('an UNKNOWN grader state refuses too — it is not the same as available', () => {
142
+ // The runner holds no push credential, so the server-side grade is the only
143
+ // review between an unattended worker and main. "I could not tell" must not
144
+ // resolve to "go ahead".
145
+ const d = decideRun({ fence: open(), graderBypassed: null });
146
+ assert.equal(d.go, false);
147
+ assert.equal(d.code, 'grader_unavailable');
148
+ assert.match(d.reason, /could not read/);
149
+ });
150
+
151
+ test('an open fence with a healthy grader goes', () => {
152
+ const d = decideRun({ fence: open(), ...GRADER_OK });
153
+ assert.equal(d.go, true);
154
+ assert.deepEqual(d.goals, [1000119]);
155
+ assert.equal(d.code, null);
156
+ });
157
+
158
+ // ── the property that binds them: fail closed ───────────────────────────────────
159
+
160
+ test('an unreadable fence permits nothing', () => {
161
+ for (const raw of [null, undefined, {}, 'nope', 42, { goals: [] }, { enabled: 'yes', goals: [] }]) {
162
+ const d = decideRun({ fence: raw, ...GRADER_OK });
163
+ assert.equal(d.go, false, `${JSON.stringify(raw)} must not read as permission`);
164
+ assert.equal(d.code, 'fence_unreadable');
165
+ }
166
+ });
167
+
168
+ test('a broken goals list degrades to an empty allowlist, and never hides the switch', () => {
169
+ // Both refuse, so this is not about safety — it is about what the operator is
170
+ // told. Someone who threw the kill switch must see `kill_switch`, not
171
+ // `fence_unreadable` because a list beside it was the wrong shape.
172
+ for (const goals of [undefined, null, 'nope', 42, {}]) {
173
+ assert.equal(decideRun({ fence: { enabled: false, paused_reason: 'owner stopped it', goals }, ...GRADER_OK }).code,
174
+ 'kill_switch', String(goals));
175
+ assert.equal(decideRun({ fence: { enabled: true, goals }, ...GRADER_OK }).code,
176
+ 'no_allowlist', 'an unusable list is an EMPTY list — the most restrictive reading available');
177
+ }
178
+ });
179
+
180
+ test('there is no input that opens the fence without all four gates clearing', () => {
181
+ // A sweep rather than a list: if a future edit adds a permissive branch, this is
182
+ // the test that finds it.
183
+ const enabledStates = [true, false];
184
+ const graderStates = [false, true, null];
185
+ const goalSets = [[], [{ goal_id: 1000119 }]];
186
+ const requested = [[], [1000119], [9999999]];
187
+ for (const enabled of enabledStates) {
188
+ for (const graderBypassed of graderStates) {
189
+ for (const goals of goalSets) {
190
+ for (const req of requested) {
191
+ const d = decideRun({ fence: { enabled, paused_reason: null, goals }, requestedGoals: req, graderBypassed });
192
+ const shouldGo = enabled
193
+ && graderBypassed === false
194
+ && goals.length > 0
195
+ && (req.length === 0 || req.includes(1000119));
196
+ assert.equal(d.go, shouldGo,
197
+ `enabled=${enabled} grader=${graderBypassed} goals=${goals.length} requested=${JSON.stringify(req)}`);
198
+ if (!d.go) assert.ok(d.code, 'every refusal must carry a code the run log can key on');
199
+ }
200
+ }
201
+ }
202
+ }
203
+ });
204
+
205
+ test('a refusal never carries goals a caller could act on', () => {
206
+ for (const d of [
207
+ decideRun({ fence: open({ enabled: false }), ...GRADER_OK }),
208
+ decideRun({ fence: open(), graderBypassed: true }),
209
+ decideRun({ fence: null, ...GRADER_OK }),
210
+ decideRun({ fence: open({ goals: [] }), ...GRADER_OK }),
211
+ ]) {
212
+ assert.deepEqual(d.goals, [], 'a refused run must hand back nothing to work on');
213
+ }
214
+ });
215
+
216
+ test('decideTask refuses a task it cannot identify', () => {
217
+ for (const task of [null, undefined, {}, { goal_id: 1000119 }]) {
218
+ assert.equal(decideTask({ task, goals: [1000119] }).go, false);
219
+ }
220
+ });
221
+
222
+ // ── the runner's own read ───────────────────────────────────────────────────────
223
+
224
+ test('an unreachable instance refuses — it does not fall back to a default', async () => {
225
+ const api = {
226
+ autonomy: { getAutonomyFence: async () => { throw new Error('ECONNREFUSED'); } },
227
+ me: { getMe: async () => { throw new Error('ECONNREFUSED'); } },
228
+ };
229
+ const { raw, graderBypassed } = await runner.readFence({ api });
230
+ assert.equal(raw, null);
231
+ assert.equal(graderBypassed, null);
232
+ assert.equal(decideRun({ fence: raw, graderBypassed }).code, 'fence_unreadable');
233
+ });
234
+
235
+ test('a server too old to report grader state is UNKNOWN, not healthy', async () => {
236
+ const api = {
237
+ autonomy: { getAutonomyFence: async () => ({ ok: true, data: open() }) },
238
+ me: { getMe: async () => ({ ok: true, data: { builder: { id: '3' } } }) }, // no grader_bypass
239
+ };
240
+ const { raw, graderBypassed } = await runner.readFence({ api });
241
+ assert.deepEqual(raw, open());
242
+ assert.equal(graderBypassed, null);
243
+ assert.equal(decideRun({ fence: raw, graderBypassed }).code, 'grader_unavailable');
244
+ });
245
+
246
+ test('a healthy read passes both facts through', async () => {
247
+ const api = {
248
+ autonomy: { getAutonomyFence: async () => ({ ok: true, data: open() }) },
249
+ me: { getMe: async () => ({ ok: true, data: { grader_bypass: false } }) },
250
+ };
251
+ const { raw, graderBypassed } = await runner.readFence({ api });
252
+ assert.equal(graderBypassed, false);
253
+ assert.equal(decideRun({ fence: raw, graderBypassed }).go, true);
254
+ });
255
+
256
+ // ── allowedGoals, on its own ────────────────────────────────────────────────────
257
+
258
+ test('no request means every allowlisted goal', () => {
259
+ const f = normaliseFence(open({ goals: [{ goal_id: 1 }, { goal_id: 2 }] }));
260
+ assert.deepEqual(allowedGoals(f, []).goals, [1, 2]);
261
+ assert.deepEqual(allowedGoals(f).goals, [1, 2]);
262
+ });
@@ -253,7 +253,15 @@ test('a goal whose tasks cannot be read is reported, not silently skipped', asyn
253
253
  // running. These pin the shape that fixes it.
254
254
 
255
255
  const goodGauge = () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } });
256
- const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300) };
256
+ const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300), goal_id: 1000119, touches: [] };
257
+ // The FENCE, faked (task 1003908). Step 0 of every iteration now reads the kill
258
+ // switch, the allowlist and the grader's availability from the server; a test that
259
+ // does not say what the fence holds is not describing a run. The fence's own
260
+ // behaviour is tests/autobongos_fence.mjs — here it is a precondition.
261
+ const openFence = () => ({
262
+ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }] },
263
+ graderBypassed: false,
264
+ });
257
265
 
258
266
  function harness(over = {}) {
259
267
  const calls = [];
@@ -261,6 +269,7 @@ function harness(over = {}) {
261
269
  const blockers = [];
262
270
  const deps = {
263
271
  gauge: over.gauge || goodGauge,
272
+ readFence: over.readFence || (async () => openFence()),
264
273
  // The LEDGER, faked. iteration() now reads the task back from Bongos after
265
274
  // every worker (task 1003903) instead of believing the worker's own verdict,
266
275
  // so a test that does not say what the ledger holds is not describing a run.
@@ -288,6 +297,56 @@ function harness(over = {}) {
288
297
  }
289
298
  const opts = { goals: [1000119], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 };
290
299
 
300
+ test('the fence is read BEFORE the gauge, and a closed one claims nothing', async () => {
301
+ // Order matters: three of the fence's four gates make every task refusable, so
302
+ // paying for a capacity read first would be wasted — and a fence checked after
303
+ // something is claimed is not a fence.
304
+ let gaugeRead = false;
305
+ const h = harness({
306
+ gauge: () => { gaugeRead = true; return goodGauge(); },
307
+ readFence: async () => ({ raw: { enabled: false, paused_reason: 'owner stopped it' }, graderBypassed: false }),
308
+ });
309
+ const row = await runner.iteration(opts, h.deps);
310
+ assert.equal(row.event, 'fenced');
311
+ assert.equal(row.code, 'kill_switch');
312
+ assert.match(row.reason, /owner stopped it/);
313
+ assert.equal(gaugeRead, false, 'the gauge must not be consulted for a run the fence already refused');
314
+ assert.deepEqual(h.calls, [], 'nothing may be created or claimed behind a closed fence');
315
+ });
316
+
317
+ test('a bypassed grader stops the runner rather than shipping unreviewed', async () => {
318
+ const h = harness({ readFence: async () => ({ ...openFence(), graderBypassed: true }) });
319
+ const row = await runner.iteration(opts, h.deps);
320
+ assert.equal(row.event, 'fenced');
321
+ assert.equal(row.code, 'grader_unavailable');
322
+ assert.deepEqual(h.calls, []);
323
+ });
324
+
325
+ test('the loop picks from the ALLOWLIST, not from what the command line asked for', async () => {
326
+ let sawGoals = null;
327
+ const h = harness({ pickTask: async (goals) => { sawGoals = goals; return { task: aTask, goalId: 1000119, skipped: [] }; } });
328
+ // The operator asks for an allowlisted goal AND one that is not on the list.
329
+ await runner.iteration({ ...opts, goals: [1000119, 9999999] }, h.deps);
330
+ assert.deepEqual(sawGoals, [1000119], 'a goal the database does not allow must never reach the picker');
331
+ });
332
+
333
+ test('a picked task in a non-allowlisted goal is refused before it is claimed', async () => {
334
+ const h = harness({ pickTask: async () => ({ task: { ...aTask, goal_id: 9999999 }, goalId: 9999999, skipped: [] }) });
335
+ const row = await runner.iteration(opts, h.deps);
336
+ assert.equal(row.event, 'fenced');
337
+ assert.equal(row.code, 'goal_not_allowlisted');
338
+ assert.deepEqual(h.calls, [], 'no worktree, no claim, no worker');
339
+ });
340
+
341
+ test('a picked task touching a protected path is refused before it is claimed', async () => {
342
+ const h = harness({ pickTask: async () => ({ task: { ...aTask, touches: ['migrations/999_x.sql', 'docs/canonical-permissions.md'] }, goalId: 1000119, skipped: [] }) });
343
+ const row = await runner.iteration(opts, h.deps);
344
+ assert.equal(row.event, 'fenced');
345
+ assert.equal(row.code, 'protected_path');
346
+ assert.ok(row.globs.length, 'the refusal must name the surface it hit');
347
+ assert.deepEqual(h.calls, []);
348
+ });
349
+
291
350
  test('an unknown gauge holds before anything is claimed', async () => {
292
351
  const h = harness({ gauge: () => ({ decision: { go: false, unknown: true, reason: 'no gauge file', sleepUntil: null } }) });
293
352
  const row = await runner.iteration(opts, h.deps);
@@ -444,3 +503,63 @@ test('each task gets a worktree name that cannot collide with another claim', ()
444
503
  assert.notEqual(a, b, 'a collision makes the server store a name the ship lock can never match (task 1004031)');
445
504
  });
446
505
 
506
+
507
+ // ── the production call shape ───────────────────────────────────────────────────
508
+ //
509
+ // THE GAP THIS CLOSES, and it is the point of the test rather than a footnote.
510
+ // `harness()` always injects deps.run, so `const runCmd = deps.run || run` at the
511
+ // top of iteration() short-circuits and never evaluates the module-level `run`
512
+ // helper. A later `const run = …` in the same function shadowed that helper
513
+ // through the whole body via its Temporal Dead Zone, so every real invocation —
514
+ // main() calls iteration(opts) with NO deps — threw `ReferenceError: Cannot
515
+ // access 'run' before initialization` on iteration's first meaningful line, while
516
+ // the entire suite stayed green. Shipped code that no test ever calls the way
517
+ // production calls it is untested code, whatever the count says.
518
+ //
519
+ // These drive iteration() with the MINIMUM injection: the two reads that would
520
+ // otherwise touch the network, and nothing else. Anything that resolves from the
521
+ // module scope must resolve for real.
522
+
523
+ test('iteration() runs with no injected run/spawn — the shape main() actually calls', async () => {
524
+ const rows = [];
525
+ const row = await runner.iteration(opts, {
526
+ readFence: async () => ({ raw: { enabled: false, paused_reason: 'off for the test' }, graderBypassed: false }),
527
+ record: (r) => { rows.push(r); return r; },
528
+ });
529
+ assert.equal(row.event, 'fenced');
530
+ assert.equal(row.code, 'kill_switch');
531
+ assert.equal(rows.length, 1);
532
+ });
533
+
534
+ test('the fenced paths reach their decision with nothing injected but the reads', async () => {
535
+ // Every refusal that can be reached before the gauge, driven through the real
536
+ // module-scope bindings. A regression in ANY of them surfaces here as a throw
537
+ // rather than as a passing test against a fully-mocked function.
538
+ const cases = [
539
+ [{ raw: { enabled: true, goals: [{ goal_id: 1000119 }] }, graderBypassed: true }, 'grader_unavailable'],
540
+ // `opts` asks for goal 1000119, so an EMPTY allowlist refuses it by name.
541
+ [{ raw: { enabled: true, goals: [] }, graderBypassed: false }, 'goal_not_allowlisted'],
542
+ [{ raw: null, graderBypassed: false }, 'fence_unreadable'],
543
+ [{ raw: { enabled: false }, graderBypassed: false }, 'kill_switch'],
544
+ ];
545
+ for (const [fenceRead, code] of cases) {
546
+ const row = await runner.iteration(opts, { readFence: async () => fenceRead, record: (r) => r });
547
+ assert.equal(row.event, 'fenced', code);
548
+ assert.equal(row.code, code);
549
+ }
550
+ });
551
+
552
+ test('the module-level exec helper is reachable from inside iteration()', async () => {
553
+ // The narrow assertion the bug would have failed: get PAST the fence, so
554
+ // `deps.run || run` has to resolve the module-level helper for real, and stop at
555
+ // the next injected seam. A name that shadows `run` reds this immediately.
556
+ let picked = false;
557
+ const row = await runner.iteration(opts, {
558
+ readFence: async () => openFence(),
559
+ gauge: goodGauge,
560
+ pickTask: async () => { picked = true; return { none: true }; },
561
+ record: (r) => r,
562
+ });
563
+ assert.equal(picked, true, 'iteration must reach the picker without touching an un-injected binding');
564
+ assert.equal(row.event, 'nothing_claimable');
565
+ });