@bongos/core 1.20.31 → 1.20.33

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.20.31",
6
- "core_contract": "1.20.31",
7
- "source_commit": "7d9880eac50e580874638a8cca10da6606c18228",
5
+ "core_version": "1.20.33",
6
+ "core_contract": "1.20.33",
7
+ "source_commit": "9b3842cb58220f06a93271aaf4d5c78088cf4374",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T21:24:24.438Z",
9
+ "built_at": "2026-09-30T22:01:27.383Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 562,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2666,
14
+ "functional_verbatim": 2671,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3254,
20
- "tree_sha256": "88a5431f2eb9f9248c57014423f4881035e2d1b9a6cee88740a6800ab933ba38",
19
+ "file_count": 3259,
20
+ "tree_sha256": "aed626c2d3ddceedaf350fb3e2b40ada409404d6f830499bb790de6dec0f03d6",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "fd78a2ab7d52f77fbd7181a64af68b8cf4c45c25693bf7dbf31e1b9bba69ada7"
2805
+ "sha256": "62c1b89b8efed5d249b25f70db5bfb238cb9240c748122406d74fdc718e8b903"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -3057,7 +3057,7 @@
3057
3057
  {
3058
3058
  "path": "docs/recipes/upgrading-the-core.md",
3059
3059
  "mode": "0000644",
3060
- "sha256": "169e4aedf245478230027ad6a202aa7dd2ce35f019d489d2ea4c0735647d0fe6"
3060
+ "sha256": "81df5ffa3f55b075b284c51790fd1fea78ae7cab6af46460fcbf648987f7eb11"
3061
3061
  },
3062
3062
  {
3063
3063
  "path": "docs/recipes/windows-builders.md",
@@ -4077,7 +4077,7 @@
4077
4077
  {
4078
4078
  "path": "modules/autonomy/cadence.js",
4079
4079
  "mode": "0000644",
4080
- "sha256": "8c482afe460f9a9a8315b549c8e48c35e42dc05ce76e708baa24133046917af0"
4080
+ "sha256": "24e717247143b6fae013c344d36f8b74dc3e4a228396dcfff39c383c2a762e6c"
4081
4081
  },
4082
4082
  {
4083
4083
  "path": "modules/autonomy/db.js",
@@ -4087,7 +4087,7 @@
4087
4087
  {
4088
4088
  "path": "modules/autonomy/digest.js",
4089
4089
  "mode": "0000644",
4090
- "sha256": "cfe22a887e5a69cf314a318f7b410c81f91cfba65f7643605e9f3ac1ebef8022"
4090
+ "sha256": "f40114fde91ae3453ed58cd4c37d0ce4b292ae1a84b3faa6af62c8f2e500a33c"
4091
4091
  },
4092
4092
  {
4093
4093
  "path": "modules/autonomy/fence.js",
@@ -7469,6 +7469,11 @@
7469
7469
  "mode": "0000644",
7470
7470
  "sha256": "a93d8946cbe4631b2af64b2e3638dca188981032a08f5d29d63e4d0adb22d235"
7471
7471
  },
7472
+ {
7473
+ "path": "modules/provisioning/migrations/provisioning_032_intent_remedied_reason.sql",
7474
+ "mode": "0000644",
7475
+ "sha256": "73794821e3beca0129a435747e89dd5755b26f054dca0d8ca0b421392abf7127"
7476
+ },
7472
7477
  {
7473
7478
  "path": "modules/provisioning/module.json",
7474
7479
  "mode": "0000644",
@@ -8987,12 +8992,12 @@
8987
8992
  {
8988
8993
  "path": "package-lock.json",
8989
8994
  "mode": "0000644",
8990
- "sha256": "892ad66ccaa864ee07860cd8578f134a72f8cc48625f728f186eaa0f994ac8ad"
8995
+ "sha256": "faabe741a65a2f632b26900d4d5aeac7e489263d0140c9d8633a1edf9761b8a9"
8991
8996
  },
8992
8997
  {
8993
8998
  "path": "package.json",
8994
8999
  "mode": "0000644",
8995
- "sha256": "fdebee1582c5673a44b97471f05814ab6bc8e850958ca3131e4a356f3a663d90"
9000
+ "sha256": "de12fffa22b571c06d6757c17cc153ef9a3867bc44893d572141bf989b80c185"
8996
9001
  },
8997
9002
  {
8998
9003
  "path": "public-docs/index.html",
@@ -9012,7 +9017,7 @@
9012
9017
  {
9013
9018
  "path": "release-notes.json",
9014
9019
  "mode": "0000644",
9015
- "sha256": "4398d89d420cf704f3030db8752e1f9e67321e43e49b0765e23329c26160385e"
9020
+ "sha256": "695627258a4d47ae703472c86e7fb573ed751e6e2d351e47441c9ff29be824e5"
9016
9021
  },
9017
9022
  {
9018
9023
  "path": "scripts/bongos-mcp.js",
@@ -9172,7 +9177,7 @@
9172
9177
  {
9173
9178
  "path": "scripts/gds/autobongos-run.js",
9174
9179
  "mode": "0000644",
9175
- "sha256": "c8e6967fe75be8573e65c1fd73caec6912aeb75648dfdb3c82a04cd7bc7f6a6d"
9180
+ "sha256": "667d8acc1ca0b709a61aebabd0ab79171873d66c32b42dcff2e86829adc2cae0"
9176
9181
  },
9177
9182
  {
9178
9183
  "path": "scripts/gds/autobongos-service.cmd",
@@ -9307,7 +9312,7 @@
9307
9312
  {
9308
9313
  "path": "scripts/gds/claim.js",
9309
9314
  "mode": "0000644",
9310
- "sha256": "78a7e407218051892c1aa0934474084deec94768fdd28fe2c3fa447fe48905fd"
9315
+ "sha256": "77c5d63df2ba881c11e184f11e7972527bac310d856a82d0d7cd234de514665c"
9311
9316
  },
9312
9317
  {
9313
9318
  "path": "scripts/gds/classify-discipline.js",
@@ -9859,6 +9864,11 @@
9859
9864
  "mode": "0000644",
9860
9865
  "sha256": "2bb8d87422e2b24db1ea9fa0c51556b09a7bea99da5af7ad3798c3b386809f4b"
9861
9866
  },
9867
+ {
9868
+ "path": "scripts/gds/move-escalation.js",
9869
+ "mode": "0000644",
9870
+ "sha256": "de4251cf593b77a9dee3590de55319127b0e84d3bb9560ce7f07ee3f4628a23e"
9871
+ },
9862
9872
  {
9863
9873
  "path": "scripts/gds/newcomer-floor.config.json",
9864
9874
  "mode": "0000644",
@@ -9977,7 +9987,7 @@
9977
9987
  {
9978
9988
  "path": "scripts/gds/provision-core-upgrade.js",
9979
9989
  "mode": "0000644",
9980
- "sha256": "58599968201f98723a531c19f77985c0b26b9afa6d6448ae4803074497a6f2ad"
9990
+ "sha256": "53904eb7a91f7f0c7c80c3caaceed29d6317652d6830e1f575198b0ba9f5158f"
9981
9991
  },
9982
9992
  {
9983
9993
  "path": "scripts/gds/provision-disconnect.js",
@@ -10032,7 +10042,7 @@
10032
10042
  {
10033
10043
  "path": "scripts/gds/provision.js",
10034
10044
  "mode": "0000644",
10035
- "sha256": "4da6314b41905bcffbc51abe127911512743ad14f8130b4c6a25c23600a3eb7f"
10045
+ "sha256": "a014e09f61a19cf16380ae35d5ab93661b65e3fd037d7fddc0fe5ab046a6e699"
10036
10046
  },
10037
10047
  {
10038
10048
  "path": "scripts/gds/publish-credential-check.js",
@@ -10647,7 +10657,7 @@
10647
10657
  {
10648
10658
  "path": "scripts/gds/upgrade-outcome.js",
10649
10659
  "mode": "0000644",
10650
- "sha256": "a4f05b5e1042cf3d8c7096ef413b47fd039a02b78f2e4dbefb5bd002973784cb"
10660
+ "sha256": "3bd19ba68a09a6739379bdd626bcf5b93addccdf1cd8774e20857dc9a66686fb"
10651
10661
  },
10652
10662
  {
10653
10663
  "path": "scripts/gds/upgrade.js",
@@ -10679,6 +10689,11 @@
10679
10689
  "mode": "0000644",
10680
10690
  "sha256": "cf034e9c4c5887dde47951272dba1e31b9b94a212640181de1cc3086e225b357"
10681
10691
  },
10692
+ {
10693
+ "path": "scripts/gds/wedge-remedy.js",
10694
+ "mode": "0000644",
10695
+ "sha256": "abe6ecedc808f3f57ef2dc5e9f953792ae26f39625425952a8ce529dbf6de576"
10696
+ },
10682
10697
  {
10683
10698
  "path": "scripts/gds/worktree-claim-guard.js",
10684
10699
  "mode": "0000644",
@@ -11142,7 +11157,7 @@
11142
11157
  {
11143
11158
  "path": "src/module-api.js",
11144
11159
  "mode": "0000644",
11145
- "sha256": "8acd38044812dee3022427fad27e12cc7f9d81a90c1a0d921e860135a30f9e43"
11160
+ "sha256": "0969a3b47823d6b24c9eaaeb777a69c2e6d20688908bb1995dd5893cd5ae6cf2"
11146
11161
  },
11147
11162
  {
11148
11163
  "path": "src/module-loader/catalog.js",
@@ -11587,7 +11602,7 @@
11587
11602
  {
11588
11603
  "path": "tests/autobongos_cadence.mjs",
11589
11604
  "mode": "0000644",
11590
- "sha256": "98f86c0f7cb84ecfe955f74a066326d82e8af1008c17d0a28c0ad40042f876b8"
11605
+ "sha256": "d6afd12b3d2da5e77f832e8206defb676840b340d6e7a5f064fc2bb96a7c2ffb"
11591
11606
  },
11592
11607
  {
11593
11608
  "path": "tests/autobongos_fence.mjs",
@@ -11602,7 +11617,7 @@
11602
11617
  {
11603
11618
  "path": "tests/autobongos_loop.mjs",
11604
11619
  "mode": "0000644",
11605
- "sha256": "e14d2c211c28ebada8e915d0d336989e74410afb68924d9b89dc8efd666457c8"
11620
+ "sha256": "2d2017585d61ee15f217bd6eaae26025732ef099dbcfebd26f3729790b8c9078"
11606
11621
  },
11607
11622
  {
11608
11623
  "path": "tests/autobongos_service_ps1.mjs",
@@ -11622,7 +11637,7 @@
11622
11637
  {
11623
11638
  "path": "tests/autonomy_digest.mjs",
11624
11639
  "mode": "0000644",
11625
- "sha256": "633ede3096916c1d5571def211fabac9ae934f48723d182c54f2fe27c2ead105"
11640
+ "sha256": "928a8a0461adab72037a03d184966e7c1e20838e72421b0ed93c6ce5b8c84520"
11626
11641
  },
11627
11642
  {
11628
11643
  "path": "tests/autonomy_gate.mjs",
@@ -11827,7 +11842,7 @@
11827
11842
  {
11828
11843
  "path": "tests/claim_error_surface.mjs",
11829
11844
  "mode": "0000644",
11830
- "sha256": "ad5b5b24942ff31930bb1c718e9a4ac7f8934dfe5d016225b6f7a3911ae50d36"
11845
+ "sha256": "7678a98254284a541684292b3615bd02729003dfddbdd9d7b24bd895f63fb908"
11831
11846
  },
11832
11847
  {
11833
11848
  "path": "tests/claim_from_task_and_home.mjs",
@@ -11902,7 +11917,7 @@
11902
11917
  {
11903
11918
  "path": "tests/cli_exit_no_abort.mjs",
11904
11919
  "mode": "0000644",
11905
- "sha256": "39b63a76b153426f486240e4c57d4fafb2b5b130fb9853c800d20d9f4e5acdde"
11920
+ "sha256": "bce64eee77c2fb5af0fdab2a1c4b7d59cd1465817724ca7065572ba466a16fe5"
11906
11921
  },
11907
11922
  {
11908
11923
  "path": "tests/cli_lib_formatters.mjs",
@@ -14214,6 +14229,11 @@
14214
14229
  "mode": "0000644",
14215
14230
  "sha256": "ee165bd5e817b909be4299014646d3c8b5e34bf00854491ea42cc5afc663fdc3"
14216
14231
  },
14232
+ {
14233
+ "path": "tests/move_escalation.mjs",
14234
+ "mode": "0000644",
14235
+ "sha256": "0406212bd27e4b878b27414625071b646e4e023af1694f8fc2449fdc61be243c"
14236
+ },
14217
14237
  {
14218
14238
  "path": "tests/my_projects_surface.mjs",
14219
14239
  "mode": "0000644",
@@ -14457,7 +14477,7 @@
14457
14477
  {
14458
14478
  "path": "tests/plain_cards.mjs",
14459
14479
  "mode": "0000644",
14460
- "sha256": "ccf7558194db8894811399aba8ec548cbb4a74b474ff6fd534d496bf98cf516f"
14480
+ "sha256": "731956f3836cd73878668d0d2707f18eaec64ed70495ce1beaf5410d3fb9af8a"
14461
14481
  },
14462
14482
  {
14463
14483
  "path": "tests/planning_session_skill.mjs",
@@ -16229,6 +16249,11 @@
16229
16249
  "mode": "0000644",
16230
16250
  "sha256": "456b20334164a710f59116d6a6c4b85090dc0e65668284d0a736e6ee1df5dea6"
16231
16251
  },
16252
+ {
16253
+ "path": "tests/wedge_remedy.mjs",
16254
+ "mode": "0000644",
16255
+ "sha256": "9810e6b2cf5ec77fad5af12b195a3ff4919143b146ff43f106c67c4090c61c25"
16256
+ },
16232
16257
  {
16233
16258
  "path": "tests/wizard_born_first_board.mjs",
16234
16259
  "mode": "0000644",
@@ -2719,5 +2719,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2719
2719
  landed since 1.20.29 with no explicit bump. run 36777226204. (task 1002620)
2720
2720
  1.20.31 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2721
2721
  landed since 1.20.30 with no explicit bump. run 36779142919. (task 1002620)
2722
+ 1.20.32 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2723
+ landed since 1.20.31 with no explicit bump. run 36781343271. (task 1002620)
2724
+ 1.20.33 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2725
+ landed since 1.20.32 with no explicit bump. run 36783021915. (task 1002620)
2722
2726
  ---------------------------------------------------------------------------
2723
2727
  ```
@@ -179,6 +179,8 @@ running a go-live.
179
179
 
180
180
  ## Gotchas
181
181
 
182
+ - **The Deploy door fixes three known snags by itself, once** (task 1004448, `scripts/gds/wedge-remedy.js`). When a core move through `/deploy` fails on a **dirty tree** (it commits the tracked changes inside the project), a **full disk** (it prunes that project's own nightly dumps down to the newest 7) or a **database behind its code** (it migrates that database with its own `PGDATABASE` before the code moves), the runner applies the fix, records a `core-upgrade-remedy` event on the project, and tries the move once more on the next tick. If the retry fails for the same reason, or the fix itself fails, the move is retired as `unknown` (`remedy_did_not_hold` / `remedy_failed`) for a person — never a second fix. The manual fixes below still apply to the unattended nightly sweep, which does not go through the door.
183
+ - **A Deploy-door move nobody can fix files a blocker and posts to Discord — once** (task 1004449, `scripts/gds/move-escalation.js`). A move retired as `unknown` (including `remedy_did_not_hold`, `remedy_failed` and a failed rollback) files one blocker (`source=provisioning`, `source_ref=core-move:<instance id>:<intent id>`) with the reason and what the move reported, and posts one notice to `#ship-news` that names the project only if it is public. The next core move on that project that lands closes the blocker by itself and posts an all-clear. Refusals the owner can act on (`needs_decision`) are shown on the deploy page instead, not filed.
182
184
  - **`--registry` needs no credential at all** (task 1003878). `@bongos/core` is **public** on npm (task 1003872), so `bongos upgrade --registry` resolves it from the default registry like any other dependency — no token, no login, and no instance `.npmrc`. An install failure on this path is therefore a *real* failure (network, registry outage, or a version that was never published), never a missing credential.
183
185
  *History, so the symptom is recognisable on an old instance:* the core used to publish private, and the instance `.npmrc` read `_authToken=<redacted> npm gives that per-project key precedence, so with the env var unset it expanded EMPTY and **masked a perfectly valid literal token in `~/.npmrc`** — npm went anonymous, the private core 404'd, and the error blamed a credential the box actually held. Task 1002754 worked around it by resolving the token in-process; task 1003878 deleted the whole mechanism along with the `.npmrc` that caused it. An instance still carrying a committed `.npmrc` from a pre-1003878 scaffold can safely delete it.
184
186
  - **A green `publish` run is not evidence of a publish.** The publish-on-merge lane succeeds on its no-op paths too (cascade terminator, armed-gate). Read the run's **job summary**, which now states the verdict explicitly: `PUBLISHED @bongos/core@<v>` vs `NO-OP — nothing was published this run`.
@@ -178,6 +178,15 @@ function classifyEvent(row = {}) {
178
178
 
179
179
  if (event === 'verify_halt') return { class: 'transient', detail: 'the ledger could not be read back after a worker' };
180
180
 
181
+ if (event === 'queue_gated') {
182
+ // A stranded ship refuses EVERY claim (task 1004396). That is somebody's
183
+ // unlanded work, not this machine breaking, so it is quiet: escalating would
184
+ // slow the runner for a fault it cannot fix. The wait already wakes early when
185
+ // main moves, and a strand landing is a merge to main.
186
+ const on = Array.isArray(row.waiting_on) && row.waiting_on.length ? row.waiting_on.map((id) => `task ${id}`).join(', ') : 'a stranded task';
187
+ return { class: 'quiet', detail: `waiting for ${on} to land — every claim is refused until then` };
188
+ }
189
+
181
190
  if (event === 'pick_failed' || event === 'worktree_failed' || event === 'claim_failed') {
182
191
  return {
183
192
  class: looksSystemic(reason) ? 'systemic' : 'transient',
@@ -173,7 +173,9 @@ function buildDigest(rows = [], { gauge = null, nowEpochS = null, sinceMs = null
173
173
  }
174
174
  }
175
175
 
176
- const held = all.filter((r) => r.event === 'hold').map((r) => ({ reason: r.reason || null, sleepUntil: r.sleep_until ?? null }));
176
+ // A queue gate (task 1004396) is a hold too: the runner chose not to claim, and
177
+ // the reason names the stranded task it is waiting on.
178
+ const held = all.filter((r) => r.event === 'hold' || r.event === 'queue_gated').map((r) => ({ reason: r.reason || null, sleepUntil: r.sleep_until ?? null }));
177
179
 
178
180
  // 3. WHAT EACH WORKER DECIDED that its spec did not determine — grouped by task,
179
181
  // because the SHAPE of this list is the signal. One task with six decisions is a
@@ -211,7 +213,8 @@ function buildDigest(rows = [], { gauge = null, nowEpochS = null, sinceMs = null
211
213
  // How the runner spent the night, so "nothing shipped" can be told apart from
212
214
  // "nothing ran".
213
215
  const KNOWN = new Set(['worked', 'fenced', 'hold', 'nothing_claimable', 'dry_run',
214
- 'pick_failed', 'worktree_failed', 'claim_failed', 'verify_halt', 'cadence', 'boot_reconcile']);
216
+ 'pick_failed', 'worktree_failed', 'claim_failed', 'verify_halt', 'cadence', 'boot_reconcile',
217
+ 'queue_gated', 'queue_gate_cleared']);
215
218
  const byEvent = {};
216
219
  let unrecognised = 0;
217
220
  for (const r of all) {
@@ -0,0 +1,19 @@
1
+ -- provisioning_032_intent_remedied_reason.sql — which known snag the runner already fixed
2
+ -- for a core move (task 1004448, goal 1000090 deploy self-heal).
3
+ --
4
+ -- WHY. A core move that fails on a known snag (a dirty checkout, a full disk, a database
5
+ -- behind its code) now has the fix applied by the runner and is tried once more
6
+ -- (scripts/gds/wedge-remedy.js). "Once" has to survive between runner ticks, so the reason
7
+ -- the fix was applied for is kept on the intent. A retry that fails on the SAME reason is
8
+ -- retired for a person instead of fixed again — never a loop.
9
+ --
10
+ -- remedied_reason: a reason code from scripts/gds/upgrade-outcome.js (fixed vocabulary,
11
+ -- never builder text). NULL = no fix has been applied for this intent.
12
+ --
13
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
14
+
15
+ BEGIN;
16
+
17
+ ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS remedied_reason text;
18
+
19
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.31",
3
+ "version": "1.20.33",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.31",
9
+ "version": "1.20.33",
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.20.31",
3
+ "version": "1.20.33",
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",
@@ -8258,5 +8258,21 @@
8258
8258
  "id": "1004447",
8259
8259
  "text": "A software update that fails for a temporary reason now waits and tries again later — after 15 minutes, then an hour, then six hours — instead of hammering the same failing step three times in a row. Failures that retrying can"
8260
8260
  }
8261
+ ],
8262
+ "1.20.32": [
8263
+ {
8264
+ "id": "1004448",
8265
+ "text": "When an update to a project gets stuck on one of three known problems (leftover file changes, a full disk, or a database that is behind), the system now fixes it by itself and tries again once. If that doesn't work, it stops a"
8266
+ }
8267
+ ],
8268
+ "1.20.33": [
8269
+ {
8270
+ "id": "1004396",
8271
+ "text": "The unattended runner no longer gets stuck retrying a task it isn't allowed to take: it moves on to the next one, and only waits when a stuck ship is blocking everyone, resuming by itself once that lands."
8272
+ },
8273
+ {
8274
+ "id": "1004449",
8275
+ "text": "When a project update fails in a way the system can't fix by itself, it now opens one blocker for a person and posts one message on Discord, instead of failing quietly. When a later update succeeds, the blocker closes by itsel"
8276
+ }
8261
8277
  ]
8262
8278
  }
@@ -189,7 +189,12 @@ async function pickTask(goals, deps = {}) {
189
189
 
190
190
  const candidates = Array.from(byId.values()).filter((t) => !seq.TERMINAL.has(t.status));
191
191
  const { order } = seq.topoOrder(candidates);
192
- const { next, skipped } = seq.pickNext(order, { rank, claimableIds });
192
+ // Tasks this process set aside after a refused claim (task 1004396) drop out
193
+ // AFTER ordering, so a dependent of one is still halted by its own unmet
194
+ // dependency rather than promoted by the task's absence.
195
+ const exclude = deps.exclude || null;
196
+ const open = exclude && exclude.size ? order.filter((t) => !exclude.has(String(t.id))) : order;
197
+ const { next, skipped } = seq.pickNext(open, { rank, claimableIds });
193
198
  if (next) return { task: next, goalId, skipped };
194
199
  for (const sk of skipped) allSkipped.push({ ...sk, goal_id: goalId });
195
200
  }
@@ -369,6 +374,75 @@ async function readFence(deps = {}) {
369
374
  return { raw, graderBypassed };
370
375
  }
371
376
 
377
+ // ── what one process remembers between iterations (task 1004396) ────────────
378
+ //
379
+ // Owner ruling, 2026-09-30: a refused claim must not put the runner into waiting
380
+ // by default. It sets THAT task aside and tries the next one. It waits only when
381
+ // there is a block and nothing else it can claim.
382
+ //
383
+ // The two refusals are told apart by claim.js's exit code, because the message is
384
+ // prose and the first cut of this read prose. The run that found this burned
385
+ // ~30 attempts on task 1004254, whose refusal quoted "Your work changes the
386
+ // tools…" — which reads like a per-task protected-path verdict and was in fact
387
+ // the plain-words line of a QUEUE-WIDE gate about a different, stranded task.
388
+ // Setting 1004254 aside would have moved the runner on to the next task, which
389
+ // the same gate refuses too.
390
+ //
391
+ // QUEUE_GATED_EXIT is pinned against claim.js's own value in a test rather than
392
+ // required from it: requiring the CLI would load its whole dependency graph into
393
+ // a process that runs for weeks, to read one integer.
394
+ const QUEUE_GATED_EXIT = 4;
395
+ // How long a task refused ON ITS OWN is left alone. Not forever: most per-task
396
+ // refusals clear (a dependency ships, a claim is released, a blocker resolves),
397
+ // and a process that remembered them for its whole life would shelve work it can
398
+ // do. Half an hour is several wakes at full speed, so a refused head of the queue
399
+ // costs one attempt per half hour instead of one per wake.
400
+ const CLAIM_SET_ASIDE_MS = 30 * 60 * 1000;
401
+ // A bound on claim attempts in ONE pass, so a queue of refusals cannot turn one
402
+ // iteration into dozens of worktree creations.
403
+ const MAX_CLAIM_ATTEMPTS = 5;
404
+
405
+ function newRunnerState() {
406
+ return { setAside: new Map(), queueGate: null };
407
+ }
408
+ const RUNNER_STATE = newRunnerState();
409
+
410
+ // The stranded task ids a queue-gated refusal names (" - task 777 Title"). An
411
+ // empty list is a legal answer: the gate is still a gate, the runner simply has
412
+ // nothing to poll and tries one claim per wake instead.
413
+ function owedTaskIds(text) {
414
+ const ids = [];
415
+ const re = /^\s*-\s*task\s+(\d+)/gm;
416
+ let m;
417
+ while ((m = re.exec(String(text || ''))) !== null) ids.push(m[1]);
418
+ return ids;
419
+ }
420
+
421
+ // Does the gate still hold? Asked of the LEDGER, one read per stranded task, and
422
+ // never by attempting a claim: a claim attempt per wake is the loop this replaces.
423
+ // An unreadable task keeps the wait — guessing "cleared" would restart the
424
+ // attempts on a read failure, the opposite of backing off.
425
+ async function queueGateHolds(queueGate, deps = {}) {
426
+ if (!queueGate.owed.length) return false;
427
+ const api = deps.api && deps.api.tasks ? deps.api : await (require('./cli-lib').cliClient)();
428
+ for (const id of queueGate.owed) {
429
+ let r = null;
430
+ try { r = await api.tasks.getTasksId({ id: Number(id) }); } catch (_) { return true; }
431
+ if (!r || !r.ok || !r.data || !r.data.task) return true;
432
+ if (r.data.task.status === 'confirmed') return true;
433
+ }
434
+ return false;
435
+ }
436
+
437
+ function queueGatedRow(queueGate, now) {
438
+ const on = queueGate.owed.length ? queueGate.owed.map((id) => `task ${id}`).join(', ') : 'a stranded confirmed task';
439
+ return {
440
+ event: 'queue_gated', waiting_on: queueGate.owed,
441
+ waited_s: Math.max(0, Math.round((now - queueGate.since) / 1000)),
442
+ reason: `every claim is refused until ${on} lands — waiting, not claiming (${queueGate.reason})`,
443
+ };
444
+ }
445
+
372
446
  async function iteration(opts, deps = {}) {
373
447
  const runCmd = deps.run || run;
374
448
  const spawnWork = deps.spawnWorker || spawnWorker;
@@ -430,53 +504,89 @@ async function iteration(opts, deps = {}) {
430
504
  }
431
505
  }
432
506
 
433
- // 2. Pick — from the ALLOWLISTED goals, never from what the command line asked
434
- // for. gate.goals is already the intersection.
435
- const picked = await pick(gate.goals, deps.pickDeps || {});
436
- if (picked.error) return log({ event: 'pick_failed', reason: picked.error });
437
- if (picked.none) {
438
- return log({
439
- event: 'nothing_claimable', goals: gate.goals,
440
- // Carried so the cadence can tell an EMPTY queue from one where every task
441
- // halts — the reasons are the matrix's own codes, safe to print.
442
- skipped: (picked.skipped || []).map((sk) => ({ id: sk.id, goal_id: sk.goal_id, reasons: (sk.reasons || []).map((r) => r.code) })),
443
- });
444
- }
445
- const task = picked.task;
446
-
447
- // 2b. The per-TASK half of the fence. sequence.js's halt matrix already refuses
448
- // a protected-path task, and this re-asks anyway — not from distrust of the
449
- // matrix, but because the matrix is a shared component with its own callers and
450
- // the fence must not be a property of whoever happens to be picking. A gate that
451
- // only holds while a collaborator keeps calling it is not a gate.
452
- //
453
- // Both halves read the task's DECLARED touches[], which is a claim, not a fact —
454
- // see the header of modules/autonomy/fence.js for why that is acceptable as the
455
- // OUTER of three layers and would not be as the only one.
456
- const taskGate = fence.decideTask({
457
- task, goals: gate.goals, protectedHits: matchProtected(task.touches || []),
458
- });
459
- if (!taskGate.go) {
460
- return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: task.id, goal_id: picked.goalId, globs: taskGate.globs || [] });
507
+ // 1c. A QUEUE-WIDE gate from an earlier pass (task 1004396). While the
508
+ // stranded task it named is still `confirmed`, every claim would be refused, so
509
+ // none is attempted. The wait wakes early when main moves (waitOrJump), which is
510
+ // exactly what the strand landing does.
511
+ const state = deps.state || RUNNER_STATE;
512
+ const now = deps.now ? deps.now() : Date.now();
513
+ if (state.queueGate) {
514
+ if (await queueGateHolds(state.queueGate, deps)) return log(queueGatedRow(state.queueGate, now));
515
+ log({ event: 'queue_gate_cleared', waited_on: state.queueGate.owed, waited_s: Math.max(0, Math.round((now - state.queueGate.since) / 1000)) });
516
+ state.queueGate = null;
461
517
  }
518
+ for (const [id, until] of state.setAside) if (until <= now) state.setAside.delete(id);
519
+
520
+ // 2–4, up to MAX_CLAIM_ATTEMPTS times: pick, fence, tree, claim. A task refused
521
+ // on its own is set aside and the NEXT one is tried in the same pass.
522
+ let task = null; let picked = null; let wtName = null; let wtPath = null;
523
+ let lastRefusal = null;
524
+ for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS && !task; attempt++) {
525
+ const exclude = new Set(state.setAside.keys());
526
+ // 2. Pick — from the ALLOWLISTED goals, never from what the command line asked
527
+ // for. gate.goals is already the intersection.
528
+ const got = await pick(gate.goals, { ...(deps.pickDeps || {}), exclude });
529
+ if (got.error) return log({ event: 'pick_failed', reason: got.error });
530
+ // A picker that hands back a set-aside task is treated as having none left:
531
+ // the exclusion is this function's contract, not the picker's courtesy.
532
+ if (got.none || exclude.has(String(got.task.id))) {
533
+ // Everything left was refused in THIS pass: return the last refusal, not
534
+ // "nothing claimable". Every claim failing at once is also what an outage
535
+ // looks like, and it must still reach the failure ladder (task 1004179).
536
+ if (lastRefusal) return lastRefusal;
537
+ return log({
538
+ event: 'nothing_claimable', goals: gate.goals,
539
+ // Carried so the cadence can tell an EMPTY queue from one where every task
540
+ // halts — the reasons are the matrix's own codes, safe to print.
541
+ skipped: (got.skipped || []).map((sk) => ({ id: sk.id, goal_id: sk.goal_id, reasons: (sk.reasons || []).map((r) => r.code) })),
542
+ set_aside: Array.from(state.setAside, ([id, until]) => ({ id, until: new Date(until).toISOString() })),
543
+ });
544
+ }
545
+ const candidate = got.task;
546
+
547
+ // 2b. The per-TASK half of the fence. sequence.js's halt matrix already refuses
548
+ // a protected-path task, and this re-asks anyway — not from distrust of the
549
+ // matrix, but because the matrix is a shared component with its own callers and
550
+ // the fence must not be a property of whoever happens to be picking. A gate that
551
+ // only holds while a collaborator keeps calling it is not a gate.
552
+ //
553
+ // Both halves read the task's DECLARED touches[], which is a claim, not a fact —
554
+ // see the header of modules/autonomy/fence.js for why that is acceptable as the
555
+ // OUTER of three layers and would not be as the only one.
556
+ const taskGate = fence.decideTask({
557
+ task: candidate, goals: gate.goals, protectedHits: matchProtected(candidate.touches || []),
558
+ });
559
+ if (!taskGate.go) {
560
+ return log({ event: 'fenced', code: taskGate.code, reason: taskGate.reason, task_id: candidate.id, goal_id: got.goalId, globs: taskGate.globs || [] });
561
+ }
462
562
 
463
- if (opts.dryRun) return log({ event: 'dry_run', task_id: task.id, title: task.title, goal_id: picked.goalId });
563
+ if (opts.dryRun) return log({ event: 'dry_run', task_id: candidate.id, title: candidate.title, goal_id: got.goalId });
464
564
 
465
- // 3. The task's own working tree.
466
- const wtName = deps.worktreeName ? deps.worktreeName(task.id) : worktreeName(task.id);
467
- const made = await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'add', wtName, '--base', 'origin/main']);
468
- if (!made.ok) {
469
- return log({ event: 'worktree_failed', task_id: task.id, worktree: wtName, reason: failureReason(made, 'worktree.js') });
470
- }
471
- const wtPath = path.join(REPO_ROOT, '.claude', 'worktrees', wtName);
472
-
473
- // 4. Claim FROM that tree, so claim.js records it as the claim's tree and the
474
- // ship lock later agrees with where the work actually is.
475
- const claimed = await runCmd(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'claim.js'), String(task.id)], { cwd: wtPath });
476
- if (!claimed.ok) {
477
- await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'remove', wtName]);
478
- return log({ event: 'claim_failed', task_id: task.id, worktree: wtName, reason: failureReason(claimed, 'claim.js') });
565
+ // 3. The task's own working tree.
566
+ const name = deps.worktreeName ? deps.worktreeName(candidate.id) : worktreeName(candidate.id);
567
+ const made = await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'add', name, '--base', 'origin/main']);
568
+ if (!made.ok) {
569
+ return log({ event: 'worktree_failed', task_id: candidate.id, worktree: name, reason: failureReason(made, 'worktree.js') });
570
+ }
571
+ const treePath = path.join(REPO_ROOT, '.claude', 'worktrees', name);
572
+
573
+ // 4. Claim FROM that tree, so claim.js records it as the claim's tree and the
574
+ // ship lock later agrees with where the work actually is.
575
+ const claimed = await runCmd(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'claim.js'), String(candidate.id)], { cwd: treePath });
576
+ if (!claimed.ok) {
577
+ await runCmd(process.execPath, [path.join('scripts', 'gds', 'worktree.js'), 'remove', name]);
578
+ const reason = failureReason(claimed, 'claim.js');
579
+ if (claimed.code === QUEUE_GATED_EXIT) {
580
+ state.queueGate = { owed: owedTaskIds(`${claimed.stderr || ''}\n${claimed.stdout || ''}`), since: now, reason };
581
+ return log({ ...queueGatedRow(state.queueGate, now), task_id: candidate.id, worktree: name });
582
+ }
583
+ state.setAside.set(String(candidate.id), now + CLAIM_SET_ASIDE_MS);
584
+ lastRefusal = log({ event: 'claim_failed', task_id: candidate.id, worktree: name, reason, set_aside_s: CLAIM_SET_ASIDE_MS / 1000 });
585
+ continue;
586
+ }
587
+ task = candidate; picked = got; wtName = name; wtPath = treePath;
479
588
  }
589
+ if (!task) return lastRefusal;
480
590
 
481
591
  // 5. Work, in that tree.
482
592
  const started = Date.now();
@@ -975,4 +1085,5 @@ module.exports = {
975
1085
  worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX, countWorkedSince, codeDrifted, loadedHead, UPGRADE_EXIT_CODE, MIN_UPTIME_BEFORE_UPGRADE_MS,
976
1086
  heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
977
1087
  isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT, failureReason,
1088
+ newRunnerState, QUEUE_GATED_EXIT, CLAIM_SET_ASIDE_MS, MAX_CLAIM_ATTEMPTS, owedTaskIds,
978
1089
  };
@@ -282,6 +282,14 @@ function claimErrorInfo(r) {
282
282
  // control characters that repaint a terminal, and a runaway one can bury the
283
283
  // guidance it was meant to add. Clip to one line, strip C0/C1 controls, cap length.
284
284
  const MAX_LISTED_TASKS = 20;
285
+
286
+ // QUEUE_GATED_EXIT — the one refusal that is about the QUEUE, not the task
287
+ // (task 1004396). REBASE_REQUIRED refuses every claim until a stranded confirmed
288
+ // task lands, so an unattended caller that reads it as "this task is refused"
289
+ // sets aside task after task for a fault none of them has. Every other refusal
290
+ // keeps exit 1. The autobongos runner is the consumer and pins the same value
291
+ // in a test.
292
+ const QUEUE_GATED_EXIT = 4;
285
293
  function clip(s, max = 160) {
286
294
  if (s == null) return '';
287
295
  // eslint-disable-next-line no-control-regex
@@ -370,7 +378,7 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
370
378
  if (t.reason) lines.push(` flagged: ${clip(t.reason)}`);
371
379
  }
372
380
  lines.push(` ${clip(d.hint, 600) || 'Clear it with /strand-fix N, or rebase the branch and re-ship — or /builder-release it to give up on one.'}`);
373
- return { lines, exit: 1 };
381
+ return { lines, exit: QUEUE_GATED_EXIT };
374
382
  }
375
383
  if (code === 'ALREADY_CLAIMED') {
376
384
  lines.push(`Task #${taskId} was just claimed by another session. Try a different task.`);
@@ -771,4 +779,4 @@ if (require.main === module) {
771
779
  });
772
780
  }
773
781
 
774
- module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan, materializeModeSkills, printRoleLine, worktreeDetectionNote };
782
+ module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan, materializeModeSkills, printRoleLine, worktreeDetectionNote, QUEUE_GATED_EXIT };