@bongos/core 1.21.13 → 1.21.14

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.13",
6
- "core_contract": "1.21.13",
7
- "source_commit": "a9ae955060881e54339da9d9cfb1cb1fa7d25671",
5
+ "core_version": "1.21.14",
6
+ "core_contract": "1.21.14",
7
+ "source_commit": "1ce8d85952de7169b5f672c8c3e3a8055431aaab",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-02T19:22:55.678Z",
9
+ "built_at": "2026-10-02T19:42:26.201Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 579,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2891,
14
+ "functional_verbatim": 2892,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3498,
20
- "tree_sha256": "1910703098217bf699609725a8605efbdcadc7aa035383cc6431a1c671e63734",
19
+ "file_count": 3499,
20
+ "tree_sha256": "87469b136b4b8c01e2b74045f76630dcad52edff25e8394663c1428642244179",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2827,7 +2827,7 @@
2827
2827
  {
2828
2828
  "path": "docs/module-api-changelog.md",
2829
2829
  "mode": "0000644",
2830
- "sha256": "b89b2eeef341e48e5bf816a53f97a91ac52317b14285a6e76af85a78bd079171"
2830
+ "sha256": "79c94a7687066e362f87810166929b406808b1f36b04b894a0e3c0a0306e7dd2"
2831
2831
  },
2832
2832
  {
2833
2833
  "path": "docs/modules-contract.md",
@@ -4152,7 +4152,7 @@
4152
4152
  {
4153
4153
  "path": "modules/autonomy/fence.js",
4154
4154
  "mode": "0000644",
4155
- "sha256": "9b320bd16414a0af957ee2a62f559cdd63cc196eceac7bcb9dca207547ed3606"
4155
+ "sha256": "5c293c02e1c112185aaf3ed3d731a1d9c938d574624acae0b7e58d575a2f30d1"
4156
4156
  },
4157
4157
  {
4158
4158
  "path": "modules/autonomy/gauge.js",
@@ -9762,12 +9762,12 @@
9762
9762
  {
9763
9763
  "path": "package-lock.json",
9764
9764
  "mode": "0000644",
9765
- "sha256": "c7e6e6a89d48813975d7054be141a240197bbc5b045cf65fa41334311e639bc7"
9765
+ "sha256": "c24ae3bb13ec063dd4ec47e93690119c9d45bcaf1cd325ebc208809185d2245b"
9766
9766
  },
9767
9767
  {
9768
9768
  "path": "package.json",
9769
9769
  "mode": "0000644",
9770
- "sha256": "350bbabd5dd0fe70a82ca0dc837802afc1e0707ef5b3d6930ce5dba7e3511ee7"
9770
+ "sha256": "5372bf5df4e17254670021fbe8be6e7ef84fe6aa6c0051d79104f9707362635f"
9771
9771
  },
9772
9772
  {
9773
9773
  "path": "public-docs/index.html",
@@ -9787,7 +9787,7 @@
9787
9787
  {
9788
9788
  "path": "release-notes.json",
9789
9789
  "mode": "0000644",
9790
- "sha256": "020aad6b4b6684d47f115dd18cf1a41142f295eb94929bc290e7fb468699a0f3"
9790
+ "sha256": "6f1ec4627d1a643864c27dbd4e06125c992d1e97a69738b101f149448d36dd8e"
9791
9791
  },
9792
9792
  {
9793
9793
  "path": "scripts/bongos-mcp.js",
@@ -9952,7 +9952,7 @@
9952
9952
  {
9953
9953
  "path": "scripts/gds/autobongos-run.js",
9954
9954
  "mode": "0000644",
9955
- "sha256": "8fec3f44a9c19413efe549f0175e3568f614f9f31d885b43699475264480054f"
9955
+ "sha256": "6c945bd585134f13e972f07225d8522c1ea045f77cffeb788e751377064d3166"
9956
9956
  },
9957
9957
  {
9958
9958
  "path": "scripts/gds/autobongos-service.cmd",
@@ -12012,7 +12012,7 @@
12012
12012
  {
12013
12013
  "path": "src/module-api.js",
12014
12014
  "mode": "0000644",
12015
- "sha256": "40026259cc69c051470e03bde81e78b1daf4aaf7c336010ca85d11dd3103e352"
12015
+ "sha256": "b1eadcb18331e93e9877413504297e502bbd495fbb8294c4864df896e16e6649"
12016
12016
  },
12017
12017
  {
12018
12018
  "path": "src/module-loader/catalog.js",
@@ -12479,6 +12479,11 @@
12479
12479
  "mode": "0000644",
12480
12480
  "sha256": "f750e0978dc811420e61ac2ae7be4abd896631447c47004b1ab662e742ec3ac3"
12481
12481
  },
12482
+ {
12483
+ "path": "tests/autobongos_host_access.mjs",
12484
+ "mode": "0000644",
12485
+ "sha256": "49c40df38ea622fc5adbb2128619f75b808694a309180cc5ab200c1f2b8387f4"
12486
+ },
12482
12487
  {
12483
12488
  "path": "tests/autobongos_loop.mjs",
12484
12489
  "mode": "0000644",
@@ -2853,5 +2853,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2853
2853
  landed since 1.21.11 with no explicit bump. run 37051031732. (task 1002620)
2854
2854
  1.21.13 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2855
2855
  landed since 1.21.12 with no explicit bump. run 37053665960. (task 1002620)
2856
+ 1.21.14 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2857
+ landed since 1.21.13 with no explicit bump. run 37055782677. (task 1002620)
2856
2858
  ---------------------------------------------------------------------------
2857
2859
  ```
@@ -48,6 +48,7 @@ const REFUSALS = {
48
48
  no_allowlist: 'no goal is allowlisted, so there is nothing this runner may work on',
49
49
  goal_not_allowlisted: 'that goal is not on the allowlist',
50
50
  protected_path: 'the task declares work on a permission, migration, infrastructure or deploy-pipeline surface',
51
+ needs_host_access: 'the work needs shell access to a live host (SSH, root, systemd) that the runner machine does not have',
51
52
  grader_unavailable: 'the grader is bypassed, and it is the only review between an unattended worker and main',
52
53
  not_permitted: 'your rank does not hold autonomy.run, so your runner may not work on this project',
53
54
  builder_paused: 'the owner paused your runner',
@@ -247,6 +248,82 @@ function decideRun({ fence: rawFence, requestedGoals = [], graderBypassed = null
247
248
  return { go: true, goals, priorityGoal, refusedGoals: refused, code: null, reason: null };
248
249
  }
249
250
 
251
+ // needsHostAccess — can this task only be done ON a live host? (task 1004537)
252
+ //
253
+ // The runner machine holds no SSH key, no root and no service environment for any
254
+ // live droplet (ADR 0031 §6, ADR 0055), so a task whose work is "edit the unit on
255
+ // the droplet" is one it can only claim, fail and hand back as an owner blocker.
256
+ // Tasks 1003969 and 1003980 did exactly that three times between them.
257
+ //
258
+ // TWO SIGNALS, in a fixed order:
259
+ //
260
+ // 1. THE EXPLICIT TAG, which wins both ways. `automation_tag` already carries a
261
+ // 'live-only' value (migration 003, the claimable views' fit rules): it
262
+ // refuses with no text needed. 'overnight-safe' is the owner saying "an
263
+ // unattended session may do this", and it silences the text rule below — so
264
+ // a false positive costs one PATCH, not a task the runner refuses forever.
265
+ //
266
+ // 2. THE TEXT, when the tag says nothing. Neither real task carried a tag (nor
267
+ // touches[] on a host path — 1003980 has none; nor a manual_degree), so an
268
+ // explicit-only rule would have skipped neither of the two shapes this exists
269
+ // for. The phrases are OPERATIONS on a host, never the nouns: "a droplet" is
270
+ // code about the DigitalOcean API, "on the production droplet" or
271
+ // "sudo systemctl restart" is work done there. Each match is returned, so the
272
+ // skip row can say which phrase refused the task.
273
+ //
274
+ // ONE MENTION IS NOT ENOUGH; it takes HOST_ACCESS_MIN_SIGNALS distinct
275
+ // phrases. Measured against the 150 ready tasks on 2026-10-02, a single hit
276
+ // was a code task describing the box in passing ("the owner's only route is
277
+ // ssh ... systemctl restart", "a manual fix already applied on the live box")
278
+ // as often as it was host work. Both real shapes carry three or more. A
279
+ // host task described in one phrase is missed and costs one claim; the
280
+ // 'live-only' tag is the fix for it, and the skip row names the phrases so a
281
+ // false positive is visible too.
282
+ //
283
+ // manual_degree is NOT a signal: it is null on most rows and the views read null
284
+ // as 5, so using it would refuse nearly everything; and a high degree says a task
285
+ // is human-heavy, not that it is host-bound.
286
+ const HOST_ACCESS_PATTERNS = [
287
+ /\/etc\/(?:systemd|caddy|nginx|cron)\b/i, // host config files
288
+ /\bsudo\s+(?:systemctl|journalctl|-u\b|cp|mv|tee|install|chown|chmod|rm|nano|vim?)\b/i,
289
+ /\bsystemctl\s+(?:daemon-reload|restart|start|stop|enable|disable|edit)\b/i,
290
+ /\b(?:root|via)\s+ssh\b/i,
291
+ /\bssh\s+(?:to|into|onto|in\s+to)\b/i,
292
+ /\b(?:on|onto|to|from)\s+the\s+(?:old|live|production|prod|control-plane|co-hosting|remote)\s+(?:droplet|box|host|server)s?\b/i,
293
+ /\blive\s+(?:droplet|box|host|server)(?:es|s)?\b/i,
294
+ ];
295
+ const HOST_ACCESS_MIN_SIGNALS = 2;
296
+
297
+ // Every match of every pattern, overlapping ones merged into one span, so
298
+ // "on the live box" (two patterns, one phrase) counts once.
299
+ function hostAccessSpans(text) {
300
+ const hits = [];
301
+ for (const re of HOST_ACCESS_PATTERNS) {
302
+ const g = new RegExp(re.source, 'gi');
303
+ let m;
304
+ while ((m = g.exec(text))) hits.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
305
+ }
306
+ hits.sort((a, b) => a.start - b.start || b.end - a.end);
307
+ const spans = [];
308
+ for (const h of hits) {
309
+ const last = spans[spans.length - 1];
310
+ if (last && h.start < last.end) { last.end = Math.max(last.end, h.end); continue; }
311
+ spans.push({ ...h });
312
+ }
313
+ return spans;
314
+ }
315
+
316
+ function needsHostAccess(task) {
317
+ if (!task || typeof task !== 'object') return { needs: false, source: null, signals: [] };
318
+ const tag = task.automation_tag;
319
+ if (tag === 'live-only') return { needs: true, source: 'automation_tag', signals: ["automation_tag 'live-only'"] };
320
+ if (tag === 'overnight-safe') return { needs: false, source: 'automation_tag', signals: [] };
321
+ const spans = hostAccessSpans(`${task.title || ''}\n${task.description || ''}`);
322
+ const signals = [...new Set(spans.map((s) => s.text.replace(/\s+/g, ' ').toLowerCase()))];
323
+ if (spans.length < HOST_ACCESS_MIN_SIGNALS) return { needs: false, source: null, signals };
324
+ return { needs: true, source: 'text', signals };
325
+ }
326
+
250
327
  // decideTask — may THIS task be handed to an unattended worker?
251
328
  //
252
329
  // `protectedHits` is the output of the ADR 0043 registry matcher run over the
@@ -273,7 +350,15 @@ function decideTask({ task, goals = [], protectedHits = [] } = {}) {
273
350
  globs,
274
351
  };
275
352
  }
353
+ const host = needsHostAccess(task);
354
+ if (host.needs) {
355
+ return {
356
+ go: false, code: 'needs_host_access',
357
+ reason: `${REFUSALS.needs_host_access} (${host.signals.join('; ')})`,
358
+ signals: host.signals,
359
+ };
360
+ }
276
361
  return { go: true, code: null, reason: null };
277
362
  }
278
363
 
279
- module.exports = { REFUSALS, normaliseFence, prioritise, allowedGoals, effectiveFence, decideRun, decideTask };
364
+ module.exports = { REFUSALS, HOST_ACCESS_PATTERNS, HOST_ACCESS_MIN_SIGNALS, normaliseFence, prioritise, allowedGoals, effectiveFence, decideRun, decideTask, needsHostAccess };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.13",
3
+ "version": "1.21.14",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.13",
9
+ "version": "1.21.14",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.13",
3
+ "version": "1.21.14",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8834,5 +8834,11 @@
8834
8834
  "id": "1004528",
8835
8835
  "text": "On the street, a pin now opens the full matter card: who raised it in their own words, exactly what would change, who has voted and what it still needs, the objections, and a short note of advice. Governors can vote for, objec"
8836
8836
  }
8837
+ ],
8838
+ "1.21.14": [
8839
+ {
8840
+ "id": "1004537",
8841
+ "text": "Autobongos no longer picks tasks that can only be done by logging into a live server. They are skipped before any claim and listed as \"needs_host_access\", so no more wasted runs and pointless owner blockers."
8842
+ }
8837
8843
  ]
8838
8844
  }
@@ -207,10 +207,22 @@ async function pickTask(goals, deps = {}) {
207
207
  // AFTER ordering, so a dependent of one is still halted by its own unmet
208
208
  // dependency rather than promoted by the task's absence.
209
209
  const exclude = deps.exclude || null;
210
- const open = exclude && exclude.size ? order.filter((t) => !exclude.has(String(t.id))) : order;
210
+ const unexcluded = exclude && exclude.size ? order.filter((t) => !exclude.has(String(t.id))) : order;
211
+ // Work this machine structurally cannot do — SSH, root, systemd on a live
212
+ // host (task 1004537) — is skipped BEFORE the claim, and listed with its
213
+ // reason. It is decided here and not in sequence.js's matrix because the
214
+ // matrix also serves /builder-sequence, where the builder may well hold the
215
+ // host access an unattended runner never has.
216
+ const hostSkipped = [];
217
+ const open = unexcluded.filter((t) => {
218
+ const host = fence.needsHostAccess(t);
219
+ if (!host.needs) return true;
220
+ hostSkipped.push({ id: t.id, title: t.title, reasons: [{ code: 'needs_host_access', detail: `${fence.REFUSALS.needs_host_access} (${host.signals.join('; ')})` }] });
221
+ return false;
222
+ });
211
223
  const { next, skipped } = seq.pickNext(open, { rank, claimableIds });
212
- if (next) return { task: next, goalId, skipped };
213
- for (const sk of skipped) allSkipped.push({ ...sk, goal_id: goalId });
224
+ if (next) return { task: next, goalId, skipped: [...hostSkipped, ...skipped] };
225
+ for (const sk of [...hostSkipped, ...skipped]) allSkipped.push({ ...sk, goal_id: goalId });
214
226
  }
215
227
  return { none: true, skipped: allSkipped };
216
228
  }
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.21.13'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.14'; // 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');
@@ -0,0 +1,176 @@
1
+ // tests/autobongos_host_access.mjs — the runner skips work it structurally
2
+ // cannot do (task 1004537).
3
+ //
4
+ // The runner machine holds no SSH key, no root and no service environment for a
5
+ // live droplet (ADR 0031 §6 / ADR 0055). Tasks 1003969 and 1003980 were still
6
+ // picked, three times between them, and each run burned a claim and filed an
7
+ // owner blocker saying the work was never doable there. These tests hold the
8
+ // fence that stops that: a host-access task is SKIPPED, before any claim, with
9
+ // the reason `needs_host_access` in nothing_claimable's skipped[], and an
10
+ // ordinary code task beside it is still picked.
11
+
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const fence = require('../modules/autonomy/fence.js');
20
+ const runner = require('../scripts/gds/autobongos-run.js');
21
+
22
+ const { needsHostAccess, decideTask } = fence;
23
+
24
+ // The two real shapes, excerpted from their task rows (touches, tags and
25
+ // manual_degree exactly as they were: nothing explicit marked either one).
26
+ const T1003969 = {
27
+ id: 1003969, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
28
+ touches: ['docs/recipes/upgrading-the-core.md'], manual_degree: null, automation_tag: null,
29
+ title: 'Install the core-update-subscription timer at the declared 15min cadence on the cloudbongos.com control plane',
30
+ description: [
31
+ 'WHAT REMAINS (host state, not in this repo): the systemd unit lives on the droplet.',
32
+ '/etc/systemd/system/cloudbongos-core-update-subscription.timer is still OnCalendar=daily with RandomizedDelaySec=1800. Change it to:',
33
+ ' OnCalendar=*:0/15',
34
+ 'then `sudo systemctl daemon-reload && sudo systemctl restart cloudbongos-core-update-subscription.timer`',
35
+ ].join('\n'),
36
+ };
37
+ const T1003980 = {
38
+ id: 1003980, goal_id: 1000119, kind: 'bug', status: 'ready', requires_rank: 'xenos',
39
+ touches: [], manual_degree: null, automation_tag: null,
40
+ title: 'Recover the five lost bug-attachment screenshots from the co-hosting box, or accept the loss on the record',
41
+ description: [
42
+ 'WHAT IS LEFT. ADR 0126 kept the old co-hosting droplet as a rollback net after the 2026-07-05 move.',
43
+ 'If its var/bug-attachments survived, the five files are recoverable by copying them onto the control-plane droplet at BUG_ATTACHMENTS_DIR.',
44
+ 'WHY IT IS A SEPARATE ROW. It needs an interactive session on two live boxes - look for var/bug-attachments on the old droplet,',
45
+ 'copy across, set BUG_ATTACHMENTS_DIR in the service environment, confirm the probe goes green.',
46
+ 'DONE WHEN: either the audit reports zero missing, or the files are confirmed unrecoverable.',
47
+ ].join('\n'),
48
+ };
49
+ const CODE_TASK = {
50
+ id: 9002, goal_id: 1000119, kind: 'feature', status: 'ready', requires_rank: 'xenos',
51
+ touches: ['scripts/gds/sequence.js'], manual_degree: null, automation_tag: null,
52
+ title: 'Teach the picker a new halt reason',
53
+ description: 'Add a halt reason to scripts/gds/sequence.js and cover it with a tests/*.mjs case. '.repeat(4)
54
+ + 'DONE WHEN: the new case passes and the old ones still do.',
55
+ };
56
+
57
+ // ── the predicate ───────────────────────────────────────────────────────────────
58
+
59
+ test('the 1003969 shape (systemd unit edited on the droplet via sudo) needs host access', () => {
60
+ const d = needsHostAccess(T1003969);
61
+ assert.equal(d.needs, true);
62
+ assert.equal(d.source, 'text');
63
+ assert.ok(d.signals.length, 'the matched phrase is named, so a false positive can be seen and overridden');
64
+ });
65
+
66
+ test('the 1003980 shape (copying files between two live boxes) needs host access', () => {
67
+ const d = needsHostAccess(T1003980);
68
+ assert.equal(d.needs, true);
69
+ assert.equal(d.source, 'text');
70
+ });
71
+
72
+ test('an ordinary code task does not', () => {
73
+ assert.equal(needsHostAccess(CODE_TASK).needs, false);
74
+ });
75
+
76
+ test("the explicit tag wins both ways: 'live-only' skips with no text, 'overnight-safe' overrides the text", () => {
77
+ const tagged = needsHostAccess({ ...CODE_TASK, automation_tag: 'live-only' });
78
+ assert.equal(tagged.needs, true);
79
+ assert.equal(tagged.source, 'automation_tag');
80
+ // An owner who reads the reason and disagrees marks the task safe; the phrase
81
+ // match must then stay quiet rather than refuse forever.
82
+ assert.equal(needsHostAccess({ ...T1003969, automation_tag: 'overnight-safe' }).needs, false);
83
+ });
84
+
85
+ test('code that merely talks ABOUT droplets is not mistaken for work ON one', () => {
86
+ const t = { ...CODE_TASK, description: 'The DigitalOcean droplet-create call in modules/box/do-client.js leaks a droplet when the API blips. Fix the retry so no droplet is orphaned. '.repeat(2) };
87
+ assert.equal(needsHostAccess(t).needs, false);
88
+ });
89
+
90
+ test('one passing mention of the box does not refuse a code task (the task 1004463 shape)', () => {
91
+ // A real ready task on 2026-10-02: a code fix whose description quotes the
92
+ // owner's current workaround. One phrase is description, not the work.
93
+ const t = { ...CODE_TASK, description: `${CODE_TASK.description} Today the owner's only route is \`ssh cloudbongos 'systemctl restart cloudbongos'\`; the hall should do it instead.` };
94
+ const d = needsHostAccess(t);
95
+ assert.equal(d.needs, false);
96
+ assert.deepEqual(d.signals, ['systemctl restart'], 'the lone phrase is still reported, so the threshold is visible');
97
+ // The same phrase twice over, as in the two real shapes, does refuse.
98
+ const twice = { ...t, description: `${t.description} Then sudo systemctl daemon-reload on the production droplet.` };
99
+ assert.equal(needsHostAccess(twice).needs, true);
100
+ });
101
+
102
+ test('a missing task is not a host-access task (decideTask refuses it on its own)', () => {
103
+ assert.equal(needsHostAccess(null).needs, false);
104
+ });
105
+
106
+ // ── decideTask refuses it, like protected_path ─────────────────────────────────
107
+
108
+ test('decideTask refuses a host-access task with code needs_host_access', () => {
109
+ const d = decideTask({ task: T1003969, goals: [1000119], protectedHits: [] });
110
+ assert.equal(d.go, false);
111
+ assert.equal(d.code, 'needs_host_access');
112
+ assert.match(d.reason, /live host/);
113
+ assert.equal(decideTask({ task: CODE_TASK, goals: [1000119], protectedHits: [] }).go, true);
114
+ });
115
+
116
+ // ── the picker: skipped with a reason, and the code task behind it is picked ───
117
+
118
+ function fakeApi(tasks, claimable) {
119
+ return {
120
+ me: { getMe: async () => ({ ok: true, data: { builder: { rank: 'archon' } } }) },
121
+ tasks: {
122
+ getTasks: async ({ query }) => ({ ok: true, data: { tasks: query.offset ? [] : tasks } }),
123
+ getTasksId: async ({ id }) => {
124
+ const t = tasks.find((x) => Number(x.id) === Number(id));
125
+ return t ? { ok: true, data: { task: { dependencies: [], dependents: [], ...t } } } : { ok: false };
126
+ },
127
+ getTasksClaimable: async () => ({ ok: true, data: { claimable: claimable.map((id) => ({ id })) } }),
128
+ },
129
+ };
130
+ }
131
+
132
+ test('pickTask walks past the host-access tasks to the code task behind them', async () => {
133
+ const api = fakeApi([T1003969, T1003980, CODE_TASK], [1003969, 1003980, 9002]);
134
+ const got = await runner.pickTask([1000119], { api, rank: 'archon' });
135
+ assert.equal(got.task && got.task.id, 9002, 'an ordinary code task is still picked');
136
+ });
137
+
138
+ test('when only host-access tasks are left, they appear in skipped[] as needs_host_access', async () => {
139
+ const api = fakeApi([T1003969, T1003980], [1003969, 1003980]);
140
+ const got = await runner.pickTask([1000119], { api, rank: 'archon' });
141
+ assert.equal(got.none, true, 'no claim is made on work the machine cannot do');
142
+ const byId = new Map(got.skipped.map((s) => [Number(s.id), s]));
143
+ for (const id of [1003969, 1003980]) {
144
+ const sk = byId.get(id);
145
+ assert.ok(sk, `task ${id} must be listed, not silently vanish`);
146
+ assert.ok(sk.reasons.some((r) => r.code === 'needs_host_access'), `task ${id} carries the reason`);
147
+ assert.equal(sk.goal_id, 1000119);
148
+ }
149
+ });
150
+
151
+ test('the nothing_claimable row the iteration writes carries needs_host_access', async () => {
152
+ const api = fakeApi([T1003969], [1003969]);
153
+ let claimed = false;
154
+ const row = await runner.iteration({ goals: [1000119] }, {
155
+ readFence: async () => ({ raw: { enabled: true, paused_reason: null, goals: [{ goal_id: 1000119 }] }, graderBypassed: false }),
156
+ gauge: () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } }),
157
+ state: runner.newRunnerState(),
158
+ pickTask: (goals, d) => runner.pickTask(goals, { ...d, api, rank: 'archon' }),
159
+ run: async () => { claimed = true; return { ok: true, code: 0, stdout: '', stderr: '' }; },
160
+ // Inert stand-ins, so a regression that DOES pick the task fails this test
161
+ // without spawning a real worker or writing to a real ledger.
162
+ spawnWorker: async () => { claimed = true; return { code: 0, envelope: null, stdout: '', stderr: '' }; },
163
+ worktreeName: () => 'autobongos-test-host-access',
164
+ gradeLedgerPath: () => path.join(os.tmpdir(), `autobongos-host-access-${process.pid}.jsonl`),
165
+ api,
166
+ verifyDeps: {
167
+ task: { id: 1003969, status: 'shipped', updated_at: new Date().toISOString() },
168
+ probeArtifact: async () => ({ checked: true, onMain: true, sha: 'a'.repeat(40), subject: 'x' }),
169
+ fileBlocker: async () => ({ filed: false }),
170
+ },
171
+ record: (r) => r,
172
+ });
173
+ assert.equal(row.event, 'nothing_claimable');
174
+ assert.equal(claimed, false, 'no worktree is made and no claim is attempted');
175
+ assert.deepEqual(row.skipped, [{ id: 1003969, goal_id: 1000119, reasons: ['needs_host_access'] }]);
176
+ });