@bongos/core 1.21.111 → 1.21.113
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 +27 -17
- package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +39 -6
- package/docs/canonical-permissions.md +6 -3
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/autobongos-run.js +20 -5
- package/scripts/gds/autobongos-tweak-lane.js +262 -0
- package/scripts/gds/claim.js +2 -0
- package/scripts/gds/tweak-halt.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/autobongos_tweak_lane.mjs +208 -0
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.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.113",
|
|
6
|
+
"core_contract": "1.21.113",
|
|
7
|
+
"source_commit": "083632759dcd4d0b7a640009d2fd5b6e02e88e8c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-09T17:
|
|
9
|
+
"built_at": "2026-10-09T17:43:10.621Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 592,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3038,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3659,
|
|
20
|
+
"tree_sha256": "1fff5d0d8056ffebe50dfbd2b871d4e2f2c2c83e0566e32ce426390de0388a50",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -612,7 +612,7 @@
|
|
|
612
612
|
{
|
|
613
613
|
"path": "docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md",
|
|
614
614
|
"mode": "0000644",
|
|
615
|
-
"sha256": "
|
|
615
|
+
"sha256": "9127ee3f769616ad16e7151cb7f5f5a461690303804ce0ee7ddf6410f101dbd0"
|
|
616
616
|
},
|
|
617
617
|
{
|
|
618
618
|
"path": "docs/adr/0044-mediterranean-palette-replacement.md",
|
|
@@ -2352,7 +2352,7 @@
|
|
|
2352
2352
|
{
|
|
2353
2353
|
"path": "docs/canonical-permissions.md",
|
|
2354
2354
|
"mode": "0000644",
|
|
2355
|
-
"sha256": "
|
|
2355
|
+
"sha256": "453a9449ea0dc1a5f50b1d786a4d5b14086423bfd02904c9a0df5c541347d144"
|
|
2356
2356
|
},
|
|
2357
2357
|
{
|
|
2358
2358
|
"path": "docs/copy-inventory.md",
|
|
@@ -2872,7 +2872,7 @@
|
|
|
2872
2872
|
{
|
|
2873
2873
|
"path": "docs/module-api-changelog.md",
|
|
2874
2874
|
"mode": "0000644",
|
|
2875
|
-
"sha256": "
|
|
2875
|
+
"sha256": "a3bef3c5acf2212482d2e29929d92f5ef3738e9b739c7e31f6c37ba4701a7253"
|
|
2876
2876
|
},
|
|
2877
2877
|
{
|
|
2878
2878
|
"path": "docs/modules-contract.md",
|
|
@@ -10172,12 +10172,12 @@
|
|
|
10172
10172
|
{
|
|
10173
10173
|
"path": "package-lock.json",
|
|
10174
10174
|
"mode": "0000644",
|
|
10175
|
-
"sha256": "
|
|
10175
|
+
"sha256": "826f2bbfa8798f0561784c494f5794b9dc1781c930687cee51920e17f1f11bf4"
|
|
10176
10176
|
},
|
|
10177
10177
|
{
|
|
10178
10178
|
"path": "package.json",
|
|
10179
10179
|
"mode": "0000644",
|
|
10180
|
-
"sha256": "
|
|
10180
|
+
"sha256": "3defe7b0c206cd3c9433361f12186de2ce84a0ed7b652a82abe285e74d95dc52"
|
|
10181
10181
|
},
|
|
10182
10182
|
{
|
|
10183
10183
|
"path": "public-docs/index.html",
|
|
@@ -10197,7 +10197,7 @@
|
|
|
10197
10197
|
{
|
|
10198
10198
|
"path": "release-notes.json",
|
|
10199
10199
|
"mode": "0000644",
|
|
10200
|
-
"sha256": "
|
|
10200
|
+
"sha256": "bf1eda9f678bc9322ef19422a006bfef706f36065e1393c4a2337a9b611279c0"
|
|
10201
10201
|
},
|
|
10202
10202
|
{
|
|
10203
10203
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -10357,7 +10357,7 @@
|
|
|
10357
10357
|
{
|
|
10358
10358
|
"path": "scripts/gds/autobongos-run.js",
|
|
10359
10359
|
"mode": "0000644",
|
|
10360
|
-
"sha256": "
|
|
10360
|
+
"sha256": "96022e88a705b3d3e6a7da77e51a1ccc8c3c590058a18605d785e1dcf2a23986"
|
|
10361
10361
|
},
|
|
10362
10362
|
{
|
|
10363
10363
|
"path": "scripts/gds/autobongos-service.cmd",
|
|
@@ -10369,6 +10369,11 @@
|
|
|
10369
10369
|
"mode": "0000644",
|
|
10370
10370
|
"sha256": "b1e038bb70ace1652b85a4d29f5a68761c6b432fd50fbfdafb5e1e38d93627de"
|
|
10371
10371
|
},
|
|
10372
|
+
{
|
|
10373
|
+
"path": "scripts/gds/autobongos-tweak-lane.js",
|
|
10374
|
+
"mode": "0000644",
|
|
10375
|
+
"sha256": "7dfe043c33b6a050d6e3fbd8912fa35775beed4f6d7da1d46a0147d226e2e6b5"
|
|
10376
|
+
},
|
|
10372
10377
|
{
|
|
10373
10378
|
"path": "scripts/gds/autobongos-verify.js",
|
|
10374
10379
|
"mode": "0000644",
|
|
@@ -10497,7 +10502,7 @@
|
|
|
10497
10502
|
{
|
|
10498
10503
|
"path": "scripts/gds/claim.js",
|
|
10499
10504
|
"mode": "0000644",
|
|
10500
|
-
"sha256": "
|
|
10505
|
+
"sha256": "b8f83e685d6e237da18a38cc50916ce76832e6638de1782fb8cfe5b4b9c04e3d"
|
|
10501
10506
|
},
|
|
10502
10507
|
{
|
|
10503
10508
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -11887,7 +11892,7 @@
|
|
|
11887
11892
|
{
|
|
11888
11893
|
"path": "scripts/gds/tweak-halt.js",
|
|
11889
11894
|
"mode": "0000644",
|
|
11890
|
-
"sha256": "
|
|
11895
|
+
"sha256": "3b521b4055e6a851dc64f4151dca4ff75231d85df930360df785d695207807dc"
|
|
11891
11896
|
},
|
|
11892
11897
|
{
|
|
11893
11898
|
"path": "scripts/gds/tweak-renders.js",
|
|
@@ -12482,7 +12487,7 @@
|
|
|
12482
12487
|
{
|
|
12483
12488
|
"path": "src/module-api.js",
|
|
12484
12489
|
"mode": "0000644",
|
|
12485
|
-
"sha256": "
|
|
12490
|
+
"sha256": "0100b0bd0d67196146680d7be8a0f67430d013fe57af2642906d708ec2599983"
|
|
12486
12491
|
},
|
|
12487
12492
|
{
|
|
12488
12493
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12974,6 +12979,11 @@
|
|
|
12974
12979
|
"mode": "0000644",
|
|
12975
12980
|
"sha256": "34ca7d75bc83d9c4a77706dd06ecc170011ef52b7ee77eea5c3b77ac514389d0"
|
|
12976
12981
|
},
|
|
12982
|
+
{
|
|
12983
|
+
"path": "tests/autobongos_tweak_lane.mjs",
|
|
12984
|
+
"mode": "0000644",
|
|
12985
|
+
"sha256": "c97a39cffaef5885e3cd48709cd879668cee228405de01f0be8615b85765548d"
|
|
12986
|
+
},
|
|
12977
12987
|
{
|
|
12978
12988
|
"path": "tests/autobongos_verify.mjs",
|
|
12979
12989
|
"mode": "0000644",
|
|
@@ -19,7 +19,7 @@ The SSH deploy failed (their box had no droplet key), but it didn't matter: the
|
|
|
19
19
|
|
|
20
20
|
[ADR 0016](0016-trust-boundary-server-enforced-permissions.md) put authority in `builders.rank` and enforced it **server-side per request** — but **only for `/api/gds/*` HTTP routes**. It explicitly does not cover two surfaces:
|
|
21
21
|
|
|
22
|
-
1. **GitHub `main` is writable by every collaborator.** The repo is a **free-plan private repo**, so GitHub branch protection is *not available* ("Upgrade to GitHub Pro…"). All collaborators (`example-owner`, `LarsCode`, `willExample`, `IAMMASTERQUA`) have full push to `main`. The grader / confirm / rank gates live inside `ship.js` and the server — a raw `git push origin main` touches neither.
|
|
22
|
+
1. **GitHub `main` is writable by every collaborator.** The repo is a **free-plan private repo**, so GitHub branch protection is *not available* ("Upgrade to GitHub Pro…"). All collaborators (`example-owner`, `LarsCode`, `willExample`, `IAMMASTERQUA`) have full push to `main`. The grader / confirm / rank gates live inside `ship.js` and the server — a raw `git push origin main` touches neither. *(As written at the time. Since 2026-08-08 `main` is protected — a pull request plus the `unit` check for every non-admin; see §"Branch-protection status" for the current probes.)*
|
|
23
23
|
2. **The legit pipeline itself distributes the dangerous credentials.** `ship.js` does the merge-to-`main` and `ssh …deploy.sh` **client-side, from the builder's machine** — so the design hands every shipping builder the exact credentials needed to bypass it.
|
|
24
24
|
|
|
25
25
|
A second, softer problem Lars raised: a sub-Metic builder had free rein to **read into and reason about the tiering/permission code** in the first place. Newcomers (`xenos`/`thetes`) should stay in their lane.
|
|
@@ -30,13 +30,13 @@ Two threads.
|
|
|
30
30
|
|
|
31
31
|
### A. Close the code-landing path (chosen: Option A now, Option B later)
|
|
32
32
|
|
|
33
|
-
The protection goal is "un-graded code cannot reach prod, and a raw push to `main` is caught." Three layers, defense-in-depth, where **layer 3 is the authoritative enforcement** (the only one that survives a raw push / `--no-verify`, given GitHub
|
|
33
|
+
The protection goal is "un-graded code cannot reach prod, and a raw push to `main` is caught." Three layers, defense-in-depth, where **layer 3 is the authoritative enforcement** (the only one that survives a raw push / `--no-verify`, given GitHub couldn't block the push on the free plan when this was written). **Since 2026-08-08 GitHub does block a non-admin's direct push** — see §"Branch-protection status"; layer 3 stays authoritative for what branch protection does not cover (the admin's direct push, a self-merged PR with zero reviews, and *who* authored a protected path — the `unit` check passes a sub-Metic author as readily as anyone):
|
|
34
34
|
|
|
35
35
|
| Layer | Mechanism | Catches | Authoritative? |
|
|
36
36
|
|---|---|---|---|
|
|
37
37
|
| 1 — pre-push hook | `.husky/pre-push` → `scripts/gds/hooks/pre-push.js`. Blocks a direct push to `main` (override `OTB_ALLOW_MAIN_PUSH=1`, used only by the gated `scripts/gds/push-main.js`), and blocks a sub-Metic push that touches a permission-sensitive path. | the cooperative / careless case before the push leaves the box | no — bypassable with `--no-verify` |
|
|
38
38
|
| 2 — grader pre-pass | `grader.permissionPathPrePass(changedFiles, builderRank)` hard-fails the grade if a sub-Metic diff touches a protected path (mirrors the route-rank pre-pass). | the honest GDS ship flow | no — skippable with `--skip-grade` / raw push |
|
|
39
|
-
| 3 — post-push main audit | `scripts/gds/main-audit.js`: reads the **real** git history + **real** builder ranks (roster API, or Postgres via `--db` on the droplet). A resolved sub-Metic author on a protected path is a hard `[BLOCKER]`. Runs nightly (`.claude/scheduled-tasks/main-audit/`) **and** as the deploy gate (`--verify-head --db` from `~/deploy.sh`, `infra/deploy-main-audit-gate.sh`). | a raw `git push origin main
|
|
39
|
+
| 3 — post-push main audit | `scripts/gds/main-audit.js`: reads the **real** git history + **real** builder ranks (roster API, or Postgres via `--db` on the droplet). A resolved sub-Metic author on a protected path is a hard `[BLOCKER]`. Runs nightly (`.claude/scheduled-tasks/main-audit/`) **and** as the deploy gate (`--verify-head --db` from `~/deploy.sh`, `infra/deploy-main-audit-gate.sh`). | a raw `git push origin main` (since 2026-08-08 only the admin's; a non-admin's is refused by GitHub), a self-merged PR, a `--no-verify`, a `--skip-grade` — anything | **yes** — independent of the builder's cooperation, and the only layer that judges the author's RANK; branch protection proves a PR + green `unit`, not who may touch a protected path |
|
|
40
40
|
|
|
41
41
|
"Option A" = the merge-to-`main` + deploy move **server-side** so builders never hold main-push/droplet-SSH at all. The protections above ship now; the *full* server-side move and the credential rotation are operator steps (blockers [#15](https://example.com/builders#/blocker/15), [#16](https://example.com/builders#/blocker/16)). Until then, the legit client-side push is allowed only through `scripts/gds/push-main.js`, and the deploy gate (layer 3) is what guarantees poison never reaches prod.
|
|
42
42
|
|
|
@@ -69,7 +69,40 @@ After shipping, the fix was adversarially red-teamed (a 4-dimension panel + per-
|
|
|
69
69
|
|
|
70
70
|
**Not yet done (finding `#7`, follow-up):** the permission-path pre-pass (layer 2) runs only client-side in `ship.js`; the server `/grade` and `/confirm` routes don't re-run it. Layer 3 is the server-side backstop today; re-running the pre-pass server-side (the server holds the live rank in `req.builder.rank`) would harden the cooperative path.
|
|
71
71
|
|
|
72
|
-
## Branch-protection status: re-verified 2026-
|
|
72
|
+
## Branch-protection status: re-verified 2026-09-25 (task [1004669](https://cloudbongos.com/builders#/task/1004669))
|
|
73
|
+
|
|
74
|
+
**Current. `main` IS protected — a pull request plus the `unit` check, for everyone but the admin.**
|
|
75
|
+
Probed 2026-09-25 with the owner's (admin) GitHub account while checking task
|
|
76
|
+
[1001433](https://cloudbongos.com/builders#/task/1001433); the protection itself landed 2026-08-08
|
|
77
|
+
(task [1002619](https://cloudbongos.com/builders#/task/1002619)):
|
|
78
|
+
|
|
79
|
+
| Probe | Result |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `GET /repos/:o/:r/branches/main` | `protected: true` |
|
|
82
|
+
| `GET /repos/:o/:r/branches/main/protection` | `required_status_checks.contexts: ["unit"]` (`strict: false`) · `required_pull_request_reviews` present, `required_approving_review_count: 0` · `enforce_admins: false` · `allow_force_pushes: false` · `allow_deletions: false` |
|
|
83
|
+
| `GET /repos/:o/:r/rulesets` | `[]` — classic branch protection, not a ruleset |
|
|
84
|
+
| Collaborators with write | four, plus the admin owner; all four are Metic or Archon on the public leaderboard |
|
|
85
|
+
|
|
86
|
+
What this means for the claims above:
|
|
87
|
+
|
|
88
|
+
- **No longer true:** "a raw `git push origin main` by any collaborator is accepted". A non-admin's
|
|
89
|
+
direct push is refused; their work reaches `main` only through a PR whose `unit` check is green.
|
|
90
|
+
Force-pushing and deleting `main` are refused for everyone.
|
|
91
|
+
- **Still true:** the **admin** can push directly (`enforce_admins: false`); a write collaborator can
|
|
92
|
+
merge **their own** PR with zero reviews (the review count is 0 — the deliberate checks-only posture
|
|
93
|
+
of ADR 0162); and a builder **below Metic** reaches `main` only through the server's own publish
|
|
94
|
+
path (ADR 0055), never with a credential of their own.
|
|
95
|
+
- **So layer 3 (`main-audit.js`) stays the authoritative rank check.** Branch protection proves a
|
|
96
|
+
change came through a PR with a green `unit` run; it says nothing about whether its author may touch
|
|
97
|
+
a protected path. Option B (blocker [17](https://cloudbongos.com/builders#/blocker/17)) is half-delivered:
|
|
98
|
+
direct push is closed for non-admins, but nothing restricts `main` to the owner plus a deploy bot.
|
|
99
|
+
|
|
100
|
+
Treat this as the freshness stamp: re-run the probes above before asserting the claim has changed,
|
|
101
|
+
and update this section — not the history below — when it does.
|
|
102
|
+
|
|
103
|
+
## Branch-protection status: 2026-07-24 (history — superseded by the section above)
|
|
104
|
+
|
|
105
|
+
_Originally headed "re-verified 2026-07-24 (task [1001840](https://cloudbongos.com/builders#/task/1001840))"; kept as written._
|
|
73
106
|
|
|
74
107
|
The 2026-07-03 authority sweep reported that `main` had since gained live branch protection (required
|
|
75
108
|
checks + code-owner review, direct push blocked), which would have demoted `main-audit.js` from
|
|
@@ -90,8 +123,8 @@ private repo with no protection available, and **layer 3 (`main-audit.js`) remai
|
|
|
90
123
|
enforcement**, not a redundant second net. Option B (blocker
|
|
91
124
|
[#17](https://cloudbongos.com/builders#/blocker/17)) is still open and still the durable closure.
|
|
92
125
|
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
This was the freshness stamp until the 2026-08-08 protection flipped it; the current stamp is the
|
|
127
|
+
2026-09-25 section above.
|
|
95
128
|
|
|
96
129
|
## G-2/G-17 resolution: Layer 1 delivered; the "path bug" refuted (2026-08-08, task 1002608)
|
|
97
130
|
|
|
@@ -290,12 +290,15 @@ Reading the code is not rank-gated today; what a builder can LAND is (next secti
|
|
|
290
290
|
ADR 0016's per-request rank check covers `/api/bongos/*` only. Two surfaces it does **not** cover —
|
|
291
291
|
`git push` to `main` and `ssh` to the droplet — were used in the Builder 25 incident to land an
|
|
292
292
|
un-graded tiering change straight on prod `main`. **Since 2026-08-08 (task 1002619), `main` HAS
|
|
293
|
-
live branch protection: the required `unit` status check
|
|
294
|
-
|
|
293
|
+
live branch protection: a pull request plus the required `unit` status check, so a non-admin's
|
|
294
|
+
direct push is refused and their work lands via PR.** Re-probed 2026-09-25 (task
|
|
295
|
+
[1004669](https://cloudbongos.com/builders#/task/1004669)): `enforce_admins` is off, so the **admin**
|
|
296
|
+
can still push directly; the PR needs **zero** approvals, so a write collaborator can merge their own;
|
|
297
|
+
force-push and deletion of `main` are refused for everyone. The protection is *checks-only* — a code-owner review requirement was briefly
|
|
295
298
|
enforced and then deliberately retired by the owner (ADR 0162, task 1002635): gates are
|
|
296
299
|
rank-consistent CI checks, never a named human's approval. (The 2026-07-24 state — no protection
|
|
297
300
|
at all, PR #19 merged with zero checks — is preserved in ADR 0043 §"Branch-protection status" as
|
|
298
|
-
history.) `main-audit.js` remains the authoritative *post-merge* layer — it catches what any
|
|
301
|
+
history, below the current 2026-09-25 probes.) `main-audit.js` remains the authoritative *post-merge* layer — it catches what any
|
|
299
302
|
pre-merge gate misses, independent of the builder's cooperation:
|
|
300
303
|
|
|
301
304
|
- A builder **below Metic** (`xenos`, `thetes`) may **not modify permission-sensitive paths** — the
|
|
@@ -3059,5 +3059,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3059
3059
|
landed since 1.21.109 with no explicit bump. run 37963285770. (task 1002620)
|
|
3060
3060
|
1.21.111 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3061
3061
|
landed since 1.21.110 with no explicit bump. run 37965024171. (task 1002620)
|
|
3062
|
+
1.21.112 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3063
|
+
landed since 1.21.111 with no explicit bump. run 37966893511. (task 1002620)
|
|
3064
|
+
1.21.113 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3065
|
+
landed since 1.21.112 with no explicit bump. run 37968179413. (task 1002620)
|
|
3062
3066
|
---------------------------------------------------------------------------
|
|
3063
3067
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.113",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.113",
|
|
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.
|
|
3
|
+
"version": "1.21.113",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -9524,5 +9524,17 @@
|
|
|
9524
9524
|
"id": "1004667",
|
|
9525
9525
|
"text": "Lost bug screenshots can now be brought back. We used to tell people a missing screenshot from the Discord bugs channel was gone for good. That was wrong: only the link expires, and the picture stays in Discord as long as the"
|
|
9526
9526
|
}
|
|
9527
|
+
],
|
|
9528
|
+
"1.21.112": [
|
|
9529
|
+
{
|
|
9530
|
+
"id": "1004669",
|
|
9531
|
+
"text": "The security record for the main branch now says what is actually true. It used to say anyone with access could push straight to the main code line. That stopped being true in August: now every change from a regular collaborat"
|
|
9532
|
+
}
|
|
9533
|
+
],
|
|
9534
|
+
"1.21.113": [
|
|
9535
|
+
{
|
|
9536
|
+
"id": "1004679",
|
|
9537
|
+
"text": "When a project turns automatic pickup on, the background runner now picks up the oldest page an artist submitted and applies it, before its other work, then leaves it waiting for the artist. It stops at the monthly limit, leav"
|
|
9538
|
+
}
|
|
9527
9539
|
]
|
|
9528
9540
|
}
|
|
@@ -45,6 +45,7 @@ const {
|
|
|
45
45
|
const { verifyShip, failureReason, recheckLands, pendingLandIds } = require('./autobongos-verify.js');
|
|
46
46
|
const gradeCap = require('./autobongos-grade-cap.js');
|
|
47
47
|
const fence = require('../../modules/autonomy/fence.js');
|
|
48
|
+
const tweakLane = require('./autobongos-tweak-lane.js');
|
|
48
49
|
// The ADR 0043 protected-path registry, reused rather than re-derived. scripts/
|
|
49
50
|
// sits outside the kernel boundary, so this direct core require is allowed here
|
|
50
51
|
// where it would not be inside modules/ — which is exactly why fence.js takes the
|
|
@@ -346,13 +347,13 @@ async function gradeCapReader({ api, taskId, ledger }) {
|
|
|
346
347
|
|
|
347
348
|
// spawnWorker — one headless session, bounded by WALL CLOCK, in the task's own
|
|
348
349
|
// working tree.
|
|
349
|
-
async function spawnWorker({ task, model, timeoutMs, cwd, gradeLedger = null, capCheck = null, deps = {} }) {
|
|
350
|
+
async function spawnWorker({ task, model, timeoutMs, cwd, gradeLedger = null, capCheck = null, prompt: givenPrompt = null, deps = {} }) {
|
|
350
351
|
const spawnFn = deps.spawn || spawn;
|
|
351
352
|
const sessionId = deps.sessionId || crypto.randomUUID(); // pinned, so the session can be resumed
|
|
352
353
|
const { bin, shell: workerShell } = deps.resolveBin ? deps.resolveBin() : resolveWorkerBin();
|
|
353
354
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
354
355
|
const args = buildWorkerArgs({ sessionId, model, isRoot });
|
|
355
|
-
const prompt = buildWorkerPrompt({ task });
|
|
356
|
+
const prompt = givenPrompt || buildWorkerPrompt({ task }); // the tweak lane names its own (task 1004679)
|
|
356
357
|
|
|
357
358
|
return new Promise((resolve) => {
|
|
358
359
|
let child;
|
|
@@ -547,9 +548,13 @@ async function iteration(opts, deps = {}) {
|
|
|
547
548
|
// always injected deps.run. Caught by the grader on task 1003908; the test that
|
|
548
549
|
// now drives iteration() with no deps is the actual fix — this rename is half.
|
|
549
550
|
const gate = fence.decideRun({ fence: raw, requestedGoals: opts.goals, graderBypassed });
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
// ADR 0371 D3 (task 1004679): the tweak lane's own setting replaces the GOAL
|
|
552
|
+
// ALLOWLIST for that lane only, so a fence that refused on goals alone still lets
|
|
553
|
+
// the lane run below; the kill switch and the grader stop refuse it here.
|
|
554
|
+
const fencedRow = () => log({ event: 'fenced', code: gate.code, reason: gate.reason, refused_goals: gate.refusedGoals || [] });
|
|
555
|
+
const goalsOnly = !gate.go && tweakLane.GOAL_ONLY_CODES.has(gate.code)
|
|
556
|
+
&& await (deps.laneSwitchOn || tweakLane.laneSwitchOn)(deps.api);
|
|
557
|
+
if (!gate.go && !goalsOnly) return fencedRow();
|
|
553
558
|
|
|
554
559
|
// 1. The gauge. Never start a task we cannot pay for out of the window.
|
|
555
560
|
const g = readGauge();
|
|
@@ -588,6 +593,16 @@ async function iteration(opts, deps = {}) {
|
|
|
588
593
|
}
|
|
589
594
|
for (const [id, until] of state.setAside) if (until <= now) state.setAside.delete(id);
|
|
590
595
|
|
|
596
|
+
// 1d. THE TWEAK LANE (task 1004679, ADR 0371 D1): before ordinary work, the
|
|
597
|
+
// oldest submitted page round the lane may take. Null means it took none.
|
|
598
|
+
const laneRow = await (deps.tweakLaneStep || tweakLane.tweakLaneStep)({ opts, state }, {
|
|
599
|
+
api: deps.api || await (require('./cli-lib').cliClient)(), log, matchProtected, runCmd, spawnWork,
|
|
600
|
+
worktreeName: deps.worktreeName || worktreeName, spawnDeps: deps, gradeCapReader,
|
|
601
|
+
gradeLedgerPath: deps.gradeLedgerPath || gradeLedgerPath, ...(deps.laneDeps || {}),
|
|
602
|
+
});
|
|
603
|
+
if (laneRow) return laneRow;
|
|
604
|
+
if (goalsOnly) return fencedRow();
|
|
605
|
+
|
|
591
606
|
// 2–4, up to MAX_CLAIM_ATTEMPTS times: pick, fence, tree, claim. A task refused
|
|
592
607
|
// on its own is set aside and the NEXT one is tried in the same pass.
|
|
593
608
|
let task = null; let picked = null; let wtName = null; let wtPath = null;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// scripts/gds/autobongos-tweak-lane.js — the Autobongos tweak lane: pick the
|
|
2
|
+
// oldest submitted page round and hand it to a worker (task 1004679 / BV2.TW19c,
|
|
3
|
+
// ADR 0371 D1, D2, D4, D6, D7).
|
|
4
|
+
//
|
|
5
|
+
// WHY. An artist who presses submit with nobody at a terminal waits forever. The
|
|
6
|
+
// runner already wakes on a cadence, holds a fence and ships through the normal
|
|
7
|
+
// grade, so the lane is a step INSIDE its iteration (D1), before ordinary work,
|
|
8
|
+
// fed by the same derived state the studio shows (D2: open_round.state ===
|
|
9
|
+
// 'submitted', oldest task id first). Everything a person's /tweak does still
|
|
10
|
+
// happens; the worker runs `/tweak --page <id> --unattended` (task 1004678).
|
|
11
|
+
//
|
|
12
|
+
// THE RULES, each its own refusal so a run-log row can say which one held:
|
|
13
|
+
// lane_off tweak.autopickup is off (D3) — the lane is silent
|
|
14
|
+
// lane_at_cap this month's marked claims reached the cap (D5)
|
|
15
|
+
// lane_rank_too_low the runner's builder is below Metic (D4) — reported ONCE
|
|
16
|
+
// round_waits_for_person this runner already applied the round twice (D6.2)
|
|
17
|
+
// round_failed_unchanged the last apply failed and the round has not moved (D6.3)
|
|
18
|
+
// page_unknown / protected_path / needs_host_access the fence over the
|
|
19
|
+
// PAGE'S files (D7) — the setting replaces only the goal
|
|
20
|
+
// allowlist, so decideTask is asked with the round's own goal
|
|
21
|
+
//
|
|
22
|
+
// The server holds the cap, the per-round limit, the artist gate and the file
|
|
23
|
+
// rule again (tasks 1004677, 1004678). This file is the runner not ASKING for
|
|
24
|
+
// what would be refused, so a quiet machine does not spend a worker learning it.
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const fence = require('../../modules/autonomy/fence');
|
|
31
|
+
|
|
32
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
33
|
+
const LANE_RANK_FLOOR = 'metic';
|
|
34
|
+
// D6.2, the same number the server refuses a third marked claim at.
|
|
35
|
+
const MAX_AUTO_APPLIES_PER_ROUND = 2;
|
|
36
|
+
const MAX_ROUNDS_READ_PER_PASS = 10;
|
|
37
|
+
// The runner's own tree names (autobongos-run.js worktreeName). Restated rather
|
|
38
|
+
// than required: requiring the runner here would be a cycle.
|
|
39
|
+
const RUNNER_TREE_RE = /^autobongos-\d+-[0-9a-f]{6}$/;
|
|
40
|
+
// The fence refusals that are about the goal allowlist and nothing else: the lane
|
|
41
|
+
// runs past them (D3). The kill switch, a paused builder and the grader stop do not.
|
|
42
|
+
const GOAL_ONLY_CODES = new Set(['goal_not_allowlisted', 'no_allowlist', 'no_builder_scope']);
|
|
43
|
+
|
|
44
|
+
// ---- pure ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
// The lane as a whole: may it try at all this pass?
|
|
47
|
+
function decideLane({ settings, rank, state = {} }) {
|
|
48
|
+
if (!settings || typeof settings !== 'object') return { go: false, code: 'lane_unreadable', quiet: true, reason: 'could not read the tweak auto-pickup setting' };
|
|
49
|
+
if (settings.mode !== 'on') return { go: false, code: 'lane_off', quiet: true, reason: 'tweak auto-pickup is off for this project' };
|
|
50
|
+
const remaining = Number.isFinite(Number(settings.remaining_this_month))
|
|
51
|
+
? Number(settings.remaining_this_month) : Number(settings.cap) - Number(settings.used_this_month);
|
|
52
|
+
if (!(remaining > 0)) return { go: false, code: 'lane_at_cap', reason: `this month's ${settings.cap} automatic page tweaks are used up — rounds wait for a person` };
|
|
53
|
+
const { RANK_TIER } = require('../../src/module-api'); // the one rank map (lazy: only when the lane is on)
|
|
54
|
+
if (!(RANK_TIER[rank] >= RANK_TIER[LANE_RANK_FLOOR])) {
|
|
55
|
+
const once = !state.laneRankReported;
|
|
56
|
+
state.laneRankReported = true;
|
|
57
|
+
return { go: false, code: 'lane_rank_too_low', quiet: !once, reason: `the runner's builder is ${rank || 'of unknown rank'}; applying a page tweak needs Metic or higher` };
|
|
58
|
+
}
|
|
59
|
+
return { go: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The submitted rounds, oldest first (D2).
|
|
63
|
+
function submittedRounds(pagesBody) {
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const p of (pagesBody && pagesBody.pages) || []) {
|
|
66
|
+
const r = p && p.open_round;
|
|
67
|
+
if (r && r.state === 'submitted' && /^[1-9][0-9]*$/.test(String(r.task_id))) out.push({ pageId: p.id, taskId: String(r.task_id) });
|
|
68
|
+
}
|
|
69
|
+
return out.sort((a, b) => Number(a.taskId) - Number(b.taskId));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// How many times THIS runner applied the round: its claims from a runner tree.
|
|
73
|
+
function autoAppliesByRunner(claims) {
|
|
74
|
+
return (claims || []).filter((c) => c && RUNNER_TREE_RE.test(String(c.worktree_name || ''))).length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pageFiles(inventory, pageId) {
|
|
78
|
+
const page = ((inventory && inventory.pages) || []).find((p) => p && p.id === pageId);
|
|
79
|
+
return page ? [...(page.files || []), ...(page.states_files || [])] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One round: may the lane take it?
|
|
83
|
+
function decideRound({ task, claims, files, failedAt, matchProtected }) {
|
|
84
|
+
if (autoAppliesByRunner(claims) >= MAX_AUTO_APPLIES_PER_ROUND) {
|
|
85
|
+
return { go: false, code: 'round_waits_for_person', reason: `applied automatically ${MAX_AUTO_APPLIES_PER_ROUND} times already — waiting for a person` };
|
|
86
|
+
}
|
|
87
|
+
if (failedAt !== undefined && failedAt === task.updated_at) {
|
|
88
|
+
return { go: false, code: 'round_failed_unchanged', reason: 'the last automatic apply failed and the round has not changed since' };
|
|
89
|
+
}
|
|
90
|
+
if (!files || !files.length) return { go: false, code: 'page_unknown', reason: 'the page inventory does not list this page\'s files' };
|
|
91
|
+
return fence.decideTask({
|
|
92
|
+
task: { ...task, touches: files },
|
|
93
|
+
goals: task.goal_id == null ? [] : [task.goal_id],
|
|
94
|
+
protectedHits: matchProtected(files),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildTweakPrompt({ task, pageId }) {
|
|
99
|
+
return [
|
|
100
|
+
`You are an unattended Autobongos worker applying ONE page tweak: task ${task.id}, page ${pageId}.`,
|
|
101
|
+
'The round is already claimed in this worktree with the auto_pickup mark.',
|
|
102
|
+
`Read modules/copy-desk/skills/tweak/SKILL.md and follow its "Unattended mode" for: /tweak --page ${pageId} --unattended`,
|
|
103
|
+
'Never ask a question. On any halt, run the tweak-halt.js line the skill names and stop.',
|
|
104
|
+
'Never pass --skip-grade, --force or --allow-empty. Your finish line is WAITING FOR THE ARTIST.',
|
|
105
|
+
].join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// After the worker: what the LEDGER says happened to the round. A held pass at
|
|
109
|
+
// completed is the success; a released round is a halt (no blocker: the round
|
|
110
|
+
// already carries its note, and D6.3 keeps the lane off it until it moves).
|
|
111
|
+
function tweakOutcome({ task, grade }) {
|
|
112
|
+
const status = task && task.status;
|
|
113
|
+
if (status === 'completed' && grade && grade.passed === true) return { shape: 'waiting_for_artist', ok: true };
|
|
114
|
+
if (status === 'ready' || status === 'backlog') return { shape: 'tweak_halted', ok: false, failed: true };
|
|
115
|
+
return { shape: 'other', ok: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- reads -----------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function readInventory(root = REPO_ROOT) {
|
|
121
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'docs', 'page-inventory.json'), 'utf8'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readRound(api, taskId) {
|
|
125
|
+
const r = await api.tasks.getTasksId({ id: Number(taskId), query: { include: 'claims,grade' } });
|
|
126
|
+
if (!r || !r.ok || !r.data || !r.data.task) return null;
|
|
127
|
+
return { task: r.data.task, claims: r.data.claims || [], grade: r.data.grade || null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// pickRound — the lane's one call per iteration. Returns { round } to work,
|
|
131
|
+
// or { none, code, reason, quiet?, skipped[] }.
|
|
132
|
+
async function pickRound(deps) {
|
|
133
|
+
const { api, state, matchProtected } = deps;
|
|
134
|
+
let settings = null;
|
|
135
|
+
try { const r = await api.projectSettings.getProjectSettingsTweakAutopickup(); if (r && r.ok) settings = r.data; } catch (_) { /* unreadable */ }
|
|
136
|
+
let rank = deps.rank;
|
|
137
|
+
if (rank === undefined) {
|
|
138
|
+
try { const me = await api.me.getMe(); rank = me && me.ok ? me.data.builder?.rank : null; } catch (_) { rank = null; }
|
|
139
|
+
}
|
|
140
|
+
const lane = decideLane({ settings, rank, state });
|
|
141
|
+
if (!lane.go) return { none: true, ...lane, skipped: [] };
|
|
142
|
+
|
|
143
|
+
let pages = null;
|
|
144
|
+
try { const r = await api.copyDesk.getCopyDeskPages(); if (r && r.ok) pages = r.data; } catch (_) { /* unreadable */ }
|
|
145
|
+
if (!pages) return { none: true, code: 'lane_unreadable', reason: 'could not read the studio\'s page list', skipped: [] };
|
|
146
|
+
let inventory = null;
|
|
147
|
+
try { inventory = (deps.readInventory || readInventory)(); } catch (_) { inventory = null; }
|
|
148
|
+
|
|
149
|
+
const skipped = [];
|
|
150
|
+
const failed = state.tweakFailed || new Map();
|
|
151
|
+
// Bounded: a backlog of rounds the lane must skip costs at most this many reads a pass.
|
|
152
|
+
for (const { pageId, taskId } of submittedRounds(pages).slice(0, MAX_ROUNDS_READ_PER_PASS)) {
|
|
153
|
+
const round = await (deps.readRound || readRound)(api, taskId);
|
|
154
|
+
if (!round) { skipped.push({ id: taskId, page_id: pageId, code: 'round_unreadable' }); continue; }
|
|
155
|
+
const files = pageFiles(inventory, pageId);
|
|
156
|
+
const d = decideRound({ task: round.task, claims: round.claims, files, failedAt: failed.get(taskId), matchProtected });
|
|
157
|
+
if (!d.go) { skipped.push({ id: taskId, page_id: pageId, code: d.code }); continue; }
|
|
158
|
+
return { round: { task: round.task, pageId, files } };
|
|
159
|
+
}
|
|
160
|
+
return { none: true, code: 'lane_empty', quiet: true, reason: 'no submitted page round the lane may take', skipped };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Is the lane's switch on? Asked only when the fence refused on goals alone, so a
|
|
164
|
+
// runner with no allowlist and the lane off reports "fenced" exactly as before.
|
|
165
|
+
// Unreadable answers no.
|
|
166
|
+
async function laneSwitchOn(api) {
|
|
167
|
+
try {
|
|
168
|
+
const client = api || await (require('./cli-lib').cliClient)();
|
|
169
|
+
const r = await client.projectSettings.getProjectSettingsTweakAutopickup();
|
|
170
|
+
return !!(r && r.ok && r.data && r.data.mode === 'on');
|
|
171
|
+
} catch (_) { return false; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- the iteration step ----------------------------------------------------
|
|
175
|
+
|
|
176
|
+
// The codes a lane refusal is logged under. A quiet one (off, nothing to do, the
|
|
177
|
+
// rank refusal after its first report) writes no row, so an idle lane costs the
|
|
178
|
+
// run log nothing.
|
|
179
|
+
function laneRow(got) {
|
|
180
|
+
return { event: 'tweak_lane', code: got.code, reason: got.reason, skipped: got.skipped || [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// workRound — tree, marked claim, one worker, then the ledger. `d` carries the
|
|
184
|
+
// runner's own helpers (runCmd, spawnWork, worktreeName, log, api) so this file
|
|
185
|
+
// adds a lane, not a second way to run a worker.
|
|
186
|
+
async function workRound({ round, opts, state }, d) {
|
|
187
|
+
const { task, pageId } = round;
|
|
188
|
+
const id = String(task.id);
|
|
189
|
+
const node = process.execPath;
|
|
190
|
+
const failedAt = async () => {
|
|
191
|
+
const after = await (d.readRound || readRound)(d.api, id).catch(() => null);
|
|
192
|
+
if (!state.tweakFailed) state.tweakFailed = new Map();
|
|
193
|
+
state.tweakFailed.set(id, after && after.task ? after.task.updated_at : task.updated_at);
|
|
194
|
+
return after;
|
|
195
|
+
};
|
|
196
|
+
const name = d.worktreeName(task.id);
|
|
197
|
+
const made = await d.runCmd(node, [path.join('scripts', 'gds', 'worktree.js'), 'add', name, '--base', 'origin/main']);
|
|
198
|
+
if (!made.ok) return d.log({ event: 'worktree_failed', lane: 'tweak', task_id: task.id, page_id: pageId, worktree: name });
|
|
199
|
+
const treePath = path.join(REPO_ROOT, '.claude', 'worktrees', name);
|
|
200
|
+
const claimed = await d.runCmd(node, [path.join(REPO_ROOT, 'scripts', 'gds', 'claim.js'), id, '--auto-pickup'], { cwd: treePath });
|
|
201
|
+
if (!claimed.ok) {
|
|
202
|
+
await d.runCmd(node, [path.join('scripts', 'gds', 'worktree.js'), 'remove', name]);
|
|
203
|
+
await failedAt();
|
|
204
|
+
return d.log({ event: 'claim_failed', lane: 'tweak', task_id: task.id, page_id: pageId, worktree: name, reason: String(claimed.stderr || claimed.stdout || '').slice(0, 400) });
|
|
205
|
+
}
|
|
206
|
+
const started = Date.now();
|
|
207
|
+
// The grade cap (task 1004536) holds here as on ordinary work: the run's own
|
|
208
|
+
// grade ledger, read after the worker, never the worker's word.
|
|
209
|
+
const gradeLedger = d.gradeLedgerPath ? d.gradeLedgerPath(task.id) : null;
|
|
210
|
+
const capCheck = d.gradeCapReader ? await d.gradeCapReader({ api: d.api, taskId: task.id, ledger: gradeLedger }) : null;
|
|
211
|
+
const res = await d.spawnWork({ task, model: opts.model, timeoutMs: opts.timeoutMs, cwd: treePath, gradeLedger, capCheck, prompt: buildTweakPrompt({ task, pageId }), deps: d.spawnDeps || {} });
|
|
212
|
+
const grades = capCheck ? await capCheck() : null;
|
|
213
|
+
if (gradeLedger) { try { fs.rmSync(gradeLedger, { force: true }); } catch (_) { /* its counts travel in the row */ } }
|
|
214
|
+
let after = await (d.readRound || readRound)(d.api, id).catch(() => null);
|
|
215
|
+
const capped = !!(grades && grades.exhausted);
|
|
216
|
+
if (after && after.task && (after.task.status === 'active' || (capped && after.task.status === 'completed' && !(after.grade && after.grade.passed === true)))) {
|
|
217
|
+
// Left held, or the grade cap reached: release with the fixed note (ADR 0371 D8).
|
|
218
|
+
await d.runCmd(node, [path.join(REPO_ROOT, 'scripts', 'gds', 'tweak-halt.js'), id, '--code', capped ? 'grade_failed' : 'worker_stopped'], { cwd: treePath });
|
|
219
|
+
after = await (d.readRound || readRound)(d.api, id).catch(() => null);
|
|
220
|
+
}
|
|
221
|
+
const out = after ? tweakOutcome(after) : { shape: 'unreadable', ok: false };
|
|
222
|
+
if (!out.ok) await failedAt();
|
|
223
|
+
return d.log({
|
|
224
|
+
event: 'worked', lane: 'tweak', task_id: task.id, title: task.title, page_id: pageId, worktree: name,
|
|
225
|
+
outcome: out.shape, ledger_status: after && after.task ? after.task.status : null,
|
|
226
|
+
...(grades && (grades.graded || grades.outages) ? { grade_rounds: grades.graded, grade_outages: grades.outages, grade_cap_reached: capped } : {}),
|
|
227
|
+
session_id: (res && res.sessionId) || null, duration_s: Math.round((Date.now() - started) / 1000),
|
|
228
|
+
// A read that fails stops the run, as an unreadable ledger does for ordinary work.
|
|
229
|
+
...(out.shape === 'unreadable' ? { event: 'verify_halt' } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// tweakLaneStep — the runner's call. Returns a run-log row when the lane did
|
|
234
|
+
// something this pass (worked, refused a claim, dry run), else null and the
|
|
235
|
+
// iteration goes on to ordinary work.
|
|
236
|
+
async function tweakLaneStep({ opts, state }, d) {
|
|
237
|
+
const got = await (d.pickRound || pickRound)({ api: d.api, state, matchProtected: d.matchProtected, readInventory: d.readInventory, readRound: d.readRound, rank: d.rank });
|
|
238
|
+
if (got.none) {
|
|
239
|
+
if (!got.quiet) d.log(laneRow(got));
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
if (opts.dryRun) return d.log({ event: 'dry_run', lane: 'tweak', task_id: got.round.task.id, page_id: got.round.pageId });
|
|
243
|
+
return workRound({ round: got.round, opts, state }, d);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
module.exports = {
|
|
247
|
+
GOAL_ONLY_CODES,
|
|
248
|
+
laneSwitchOn,
|
|
249
|
+
tweakLaneStep,
|
|
250
|
+
workRound,
|
|
251
|
+
MAX_AUTO_APPLIES_PER_ROUND,
|
|
252
|
+
LANE_RANK_FLOOR,
|
|
253
|
+
decideLane,
|
|
254
|
+
submittedRounds,
|
|
255
|
+
autoAppliesByRunner,
|
|
256
|
+
pageFiles,
|
|
257
|
+
decideRound,
|
|
258
|
+
buildTweakPrompt,
|
|
259
|
+
tweakOutcome,
|
|
260
|
+
readRound,
|
|
261
|
+
pickRound,
|
|
262
|
+
};
|
package/scripts/gds/claim.js
CHANGED
|
@@ -606,6 +606,8 @@ async function main() {
|
|
|
606
606
|
worktree_name: worktree,
|
|
607
607
|
head_sha: headSha,
|
|
608
608
|
enforce_touches: enforceTouches,
|
|
609
|
+
// --auto-pickup (task 1004679, ADR 0371 D5): the Autobongos tweak lane's mark.
|
|
610
|
+
...(args.includes('--auto-pickup') ? { auto_pickup: true } : {}),
|
|
609
611
|
},
|
|
610
612
|
});
|
|
611
613
|
let r = await postClaim();
|
|
@@ -29,6 +29,7 @@ const HALT_NOTES = Object.freeze({
|
|
|
29
29
|
ship_check_stale: 'The automatic apply stopped because the pre-ship checks named something stale it may not fix by itself.',
|
|
30
30
|
grader_unavailable: 'The quality review could not run, so the automatic apply stopped. The round waits for the next try or a person.',
|
|
31
31
|
grade_failed: 'The quality review did not pass the automatic apply. The round waits for a person.',
|
|
32
|
+
worker_stopped: 'The automatic apply stopped before it finished, without saying why. The round waits for the next try or a person.',
|
|
32
33
|
outside_page: 'The automatic apply would have changed a file outside this page, so it stopped.',
|
|
33
34
|
});
|
|
34
35
|
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.113'; // 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,208 @@
|
|
|
1
|
+
// tests/autobongos_tweak_lane.mjs — the Autobongos tweak lane (task 1004679 /
|
|
2
|
+
// BV2.TW19c, ADR 0371 D1, D2, D4, D6, D7).
|
|
3
|
+
//
|
|
4
|
+
// The REAL picker (pickRound) and the REAL fence (modules/autonomy/fence.js +
|
|
5
|
+
// the protected-path matcher) over a fake server, one fixture per rule: off, at
|
|
6
|
+
// the cap, a protected page file, a round applied twice, a failed apply, a runner
|
|
7
|
+
// below Metic. Then the REAL iteration(): a round is worked before ordinary work,
|
|
8
|
+
// with a marked claim and the unattended prompt; with nothing for the lane, an
|
|
9
|
+
// ordinary task is still picked; and a goal-only fence lets the lane run while the
|
|
10
|
+
// kill switch and the grader stop do not.
|
|
11
|
+
//
|
|
12
|
+
// Run: node --test tests/autobongos_tweak_lane.mjs
|
|
13
|
+
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
const lane = require('../scripts/gds/autobongos-tweak-lane.js');
|
|
20
|
+
const runner = require('../scripts/gds/autobongos-run.js');
|
|
21
|
+
const { matchProtected } = require('../src/bongos/permission-path-check');
|
|
22
|
+
|
|
23
|
+
const INV = { pages: [
|
|
24
|
+
{ id: 'landing:index', files: ['modules/public-landing/public/index.html'], states_files: [] },
|
|
25
|
+
{ id: 'landing:other', files: ['modules/public-landing/public/other.html'], states_files: [] },
|
|
26
|
+
{ id: 'builders:core', files: ['CLAUDE.md'], states_files: [] },
|
|
27
|
+
] };
|
|
28
|
+
const ON = { mode: 'on', cap: 20, used_this_month: 3, remaining_this_month: 17 };
|
|
29
|
+
const round = (id, over = {}) => ({ task: { id, title: `Tweak ${id}`, status: 'ready', source: 'page-tweak', goal_id: 1000095, description: 'Apply the round.', updated_at: 't1', ...over }, claims: [], grade: null });
|
|
30
|
+
|
|
31
|
+
// A fake server: the setting, the page list, and each round's task + claims.
|
|
32
|
+
function fakeApi({ settings = ON, rank = 'metic', pages, rounds = {} } = {}) {
|
|
33
|
+
return {
|
|
34
|
+
projectSettings: { getProjectSettingsTweakAutopickup: async () => (settings ? { ok: true, data: settings } : { ok: false }) },
|
|
35
|
+
me: { getMe: async () => ({ ok: true, data: { builder: { rank }, grader_bypass: false } }) },
|
|
36
|
+
copyDesk: { getCopyDeskPages: async () => ({ ok: true, data: { pages } }) },
|
|
37
|
+
tasks: { getTasksId: async ({ id }) => (rounds[id] ? { ok: true, data: { task: rounds[id].task, claims: rounds[id].claims, grade: rounds[id].grade } } : { ok: false }) },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const submitted = (id, taskId) => ({ id, open_round: { task_id: String(taskId), state: 'submitted' } });
|
|
41
|
+
const pick = (api, state = {}) => lane.pickRound({ api, state, matchProtected, readInventory: () => INV });
|
|
42
|
+
|
|
43
|
+
test('the oldest submitted round is picked, by task id, and only submitted ones', async () => {
|
|
44
|
+
const api = fakeApi({
|
|
45
|
+
pages: [submitted('landing:other', 902), submitted('landing:index', 901), { id: 'x:y', open_round: { task_id: '900', state: 'drafting' } }],
|
|
46
|
+
rounds: { 901: round(901), 902: round(902) },
|
|
47
|
+
});
|
|
48
|
+
const got = await pick(api);
|
|
49
|
+
assert.equal(got.round.task.id, 901);
|
|
50
|
+
assert.equal(got.round.pageId, 'landing:index');
|
|
51
|
+
assert.deepEqual(got.round.files, ['modules/public-landing/public/index.html'], 'the page\'s files are the touches');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('OFF: the lane is silent and takes nothing', async () => {
|
|
55
|
+
const got = await pick(fakeApi({ settings: { ...ON, mode: 'off' }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }));
|
|
56
|
+
assert.equal(got.code, 'lane_off');
|
|
57
|
+
assert.equal(got.quiet, true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('AT THE CAP: refused, and it says the rounds wait for a person', async () => {
|
|
61
|
+
const got = await pick(fakeApi({ settings: { ...ON, used_this_month: 20, remaining_this_month: 0 }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }));
|
|
62
|
+
assert.equal(got.code, 'lane_at_cap');
|
|
63
|
+
assert.match(got.reason, /wait for a person/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('A PROTECTED PAGE FILE: the round is left for a person and the next one is taken', async () => {
|
|
67
|
+
const got = await pick(fakeApi({
|
|
68
|
+
pages: [submitted('builders:core', 900), submitted('landing:index', 901)],
|
|
69
|
+
rounds: { 900: round(900), 901: round(901) },
|
|
70
|
+
}));
|
|
71
|
+
assert.equal(got.round.task.id, 901);
|
|
72
|
+
const d = lane.decideRound({ task: round(900).task, claims: [], files: ['CLAUDE.md'], matchProtected });
|
|
73
|
+
assert.equal(d.code, 'protected_path', 'the fence\'s own refusal, over the page\'s files');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('HOST ACCESS: the fence\'s host check runs on the round too', () => {
|
|
77
|
+
const t = { ...round(1).task, automation_tag: 'live-only' };
|
|
78
|
+
assert.equal(lane.decideRound({ task: t, claims: [], files: ['modules/public-landing/public/index.html'], matchProtected }).code, 'needs_host_access');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('SENT BACK TWICE: after two applies by this runner the round waits for a person', async () => {
|
|
82
|
+
const runnerClaim = (n) => ({ worktree_name: `autobongos-901-${n}` });
|
|
83
|
+
const twice = round(901); twice.claims = [runnerClaim('aaaaaa'), runnerClaim('bbbbbb')];
|
|
84
|
+
const got = await pick(fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: twice } }));
|
|
85
|
+
assert.equal(got.none, true);
|
|
86
|
+
assert.deepEqual(got.skipped.map((s) => s.code), ['round_waits_for_person']);
|
|
87
|
+
// A person's claims, or one runner apply, do not count against it.
|
|
88
|
+
const once = round(901); once.claims = [runnerClaim('aaaaaa'), { worktree_name: null }, { worktree_name: 'fix-copy-12ab' }];
|
|
89
|
+
assert.equal((await pick(fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: once } }))).round.task.id, 901);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('A FAILED APPLY: skipped until the round\'s updated_at moves', async () => {
|
|
93
|
+
const state = { tweakFailed: new Map([['901', 't1']]) };
|
|
94
|
+
const api = (updated) => fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901, { updated_at: updated }) } });
|
|
95
|
+
const same = await pick(api('t1'), state);
|
|
96
|
+
assert.deepEqual(same.skipped.map((s) => s.code), ['round_failed_unchanged']);
|
|
97
|
+
assert.equal((await pick(api('t2'), state)).round.task.id, 901, 'the artist edited or resubmitted it');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('BELOW METIC: reported once, then quiet', async () => {
|
|
101
|
+
const state = {};
|
|
102
|
+
const api = fakeApi({ rank: 'thetes', pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } });
|
|
103
|
+
const first = await pick(api, state);
|
|
104
|
+
assert.equal(first.code, 'lane_rank_too_low');
|
|
105
|
+
assert.equal(first.quiet, false);
|
|
106
|
+
const second = await pick(api, state);
|
|
107
|
+
assert.equal(second.code, 'lane_rank_too_low');
|
|
108
|
+
assert.equal(second.quiet, true, 'the lane stops saying it');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the outcome is read from the ledger: a held pass is the success, a released round a halt', () => {
|
|
112
|
+
assert.equal(lane.tweakOutcome({ task: { status: 'completed' }, grade: { passed: true } }).shape, 'waiting_for_artist');
|
|
113
|
+
assert.equal(lane.tweakOutcome({ task: { status: 'ready' }, grade: null }).failed, true);
|
|
114
|
+
assert.equal(lane.tweakOutcome({ task: { status: 'completed' }, grade: { passed: false } }).ok, false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---- the real iteration() ---------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const goodGauge = () => ({ decision: { go: true, unknown: false, sleepUntil: null, reason: 'headroom' } });
|
|
120
|
+
const openFence = (goals = [{ goal_id: 1000119 }]) => ({ raw: { enabled: true, paused_reason: null, goals }, graderBypassed: false });
|
|
121
|
+
const aTask = { id: 4242, title: 'Do the thing', kind: 'feature', description: 'd'.repeat(300), goal_id: 1000119, touches: [] };
|
|
122
|
+
const opts = { goals: [1000119], maxTasks: 1, dryRun: false, model: 'm', timeoutMs: 1000 };
|
|
123
|
+
|
|
124
|
+
function harness({ api, fenceRead = openFence(), afterStatus = 'completed', afterGrade = { passed: true } } = {}) {
|
|
125
|
+
const calls = []; const rows = []; let picked = 0;
|
|
126
|
+
const after = { ...round(901, { status: afterStatus, updated_at: 't9' }), grade: afterGrade };
|
|
127
|
+
const deps = {
|
|
128
|
+
gauge: goodGauge, readFence: async () => fenceRead, api, state: runner.newRunnerState(),
|
|
129
|
+
record: (r) => { rows.push(r); return r; },
|
|
130
|
+
worktreeName: (id) => `autobongos-${id}-abc123`,
|
|
131
|
+
pickTask: async () => { picked++; return { task: aTask, goalId: 1000119, skipped: [] }; },
|
|
132
|
+
run: async (bin, args, o = {}) => { calls.push({ script: String(args[0]).split(/[\\/]/).pop(), args: args.slice(1), cwd: o.cwd || null }); return { ok: true, code: 0, stdout: '', stderr: '' }; },
|
|
133
|
+
spawnWorker: async (a) => { calls.push({ script: 'WORKER', prompt: a.prompt }); return { envelope: { structured_output: { outcome: 'shipped' } }, exitCode: 0, sessionId: 's' }; },
|
|
134
|
+
laneDeps: { readInventory: () => INV, readRound: async (_api, id) => (String(id) === '901' ? after : null) },
|
|
135
|
+
verifyDeps: { task: { id: 4242, status: 'shipped', updated_at: new Date().toISOString() }, probeArtifact: async () => ({ checked: true, onMain: true }), fileBlocker: async () => ({ filed: true }) },
|
|
136
|
+
};
|
|
137
|
+
return { deps, calls, rows, pickedCount: () => picked };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
test('iteration: a submitted round is worked BEFORE ordinary work — marked claim, unattended prompt', async () => {
|
|
141
|
+
const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }) });
|
|
142
|
+
const row = await runner.iteration(opts, h.deps);
|
|
143
|
+
assert.equal(row.event, 'worked');
|
|
144
|
+
assert.equal(row.lane, 'tweak');
|
|
145
|
+
assert.equal(row.outcome, 'waiting_for_artist');
|
|
146
|
+
assert.equal(h.pickedCount(), 0, 'ordinary work waits for the next pass');
|
|
147
|
+
const claim = h.calls.find((c) => c.script === 'claim.js');
|
|
148
|
+
assert.deepEqual(claim.args, ['901', '--auto-pickup']);
|
|
149
|
+
const worker = h.calls.find((c) => c.script === 'WORKER');
|
|
150
|
+
assert.match(worker.prompt, /\/tweak --page landing:index --unattended/);
|
|
151
|
+
assert.match(worker.prompt, /Never pass --skip-grade/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('iteration: a halted round is remembered as failed at its new updated_at, with no blocker', async () => {
|
|
155
|
+
const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), afterStatus: 'ready', afterGrade: null });
|
|
156
|
+
const row = await runner.iteration(opts, h.deps);
|
|
157
|
+
assert.equal(row.outcome, 'tweak_halted');
|
|
158
|
+
assert.equal(h.deps.state.tweakFailed.get('901'), 't9');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('iteration: with nothing for the lane, an ordinary task is still picked', async () => {
|
|
162
|
+
const h = harness({ api: fakeApi({ pages: [] }) });
|
|
163
|
+
const row = await runner.iteration(opts, h.deps);
|
|
164
|
+
assert.equal(h.pickedCount(), 1);
|
|
165
|
+
assert.equal(row.task_id, 4242);
|
|
166
|
+
assert.equal(row.lane, undefined);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('iteration: a fence refusing on GOALS alone still lets the lane run; the kill switch does not', async () => {
|
|
170
|
+
const api = fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } });
|
|
171
|
+
const goalsOnly = harness({ api, fenceRead: openFence([]) });
|
|
172
|
+
assert.equal((await runner.iteration(opts, goalsOnly.deps)).lane, 'tweak');
|
|
173
|
+
|
|
174
|
+
const empty = harness({ api: fakeApi({ pages: [] }), fenceRead: openFence([]) });
|
|
175
|
+
const r2 = await runner.iteration(opts, empty.deps);
|
|
176
|
+
assert.equal(r2.event, 'fenced', 'no round: the goal refusal is reported as before');
|
|
177
|
+
assert.equal(empty.pickedCount(), 0);
|
|
178
|
+
|
|
179
|
+
const killed = harness({ api, fenceRead: { raw: { enabled: false }, graderBypassed: false } });
|
|
180
|
+
const r3 = await runner.iteration(opts, killed.deps);
|
|
181
|
+
assert.equal(r3.code, 'kill_switch');
|
|
182
|
+
assert.deepEqual(killed.calls, []);
|
|
183
|
+
|
|
184
|
+
const offLane = harness({ api: fakeApi({ settings: { ...ON, mode: 'off' }, pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), fenceRead: openFence([]) });
|
|
185
|
+
assert.equal((await runner.iteration(opts, offLane.deps)).event, 'fenced', 'lane off: exactly the old fenced row');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('iteration: the grade cap holds on a tweak worker — two failed grades release the round to a person', async () => {
|
|
189
|
+
const h = harness({ api: fakeApi({ pages: [submitted('landing:index', 901)], rounds: { 901: round(901) } }), afterGrade: { passed: false } });
|
|
190
|
+
let capGiven = null;
|
|
191
|
+
h.deps.gradeLedgerPath = () => null;
|
|
192
|
+
h.deps.laneDeps.gradeCapReader = async () => async () => ({ graded: 2, fails: 2, outages: 0, exhausted: true });
|
|
193
|
+
const spawn = h.deps.spawnWorker;
|
|
194
|
+
h.deps.spawnWorker = async (a) => { capGiven = a.capCheck; return spawn(a); };
|
|
195
|
+
const row = await runner.iteration(opts, h.deps);
|
|
196
|
+
assert.equal(typeof capGiven, 'function', 'the worker is handed the cap check');
|
|
197
|
+
const halt = h.calls.find((c) => c.script === 'tweak-halt.js');
|
|
198
|
+
assert.deepEqual(halt && halt.args, ['901', '--code', 'grade_failed']);
|
|
199
|
+
assert.equal(row.grade_cap_reached, true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('a backlog of rounds the lane must skip costs at most ten reads a pass', async () => {
|
|
203
|
+
let reads = 0;
|
|
204
|
+
const pages = Array.from({ length: 25 }, (_, i) => submitted('builders:core', 1000 + i));
|
|
205
|
+
const api = fakeApi({ pages });
|
|
206
|
+
await lane.pickRound({ api, state: {}, matchProtected, readInventory: () => INV, readRound: async () => { reads++; return round(1); } });
|
|
207
|
+
assert.equal(reads, 10);
|
|
208
|
+
});
|