@bongos/core 1.19.80 → 1.19.82

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.19.80",
6
- "core_contract": "1.19.80",
7
- "source_commit": "e9830526a46cd238c6ea856406a1bcde6fcb6e4f",
5
+ "core_version": "1.19.82",
6
+ "core_contract": "1.19.82",
7
+ "source_commit": "9ca69eb29ee6b3ee602e17cbf1d1ef1ebc9df5f9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T23:02:51.567Z",
9
+ "built_at": "2026-08-12T23:14:31.232Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 295,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1226,
14
+ "functional_verbatim": 1227,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1542,
20
- "tree_sha256": "8efe860e490a9f9bca1f3739c652e205e29d50643e86361a1eb631f7474a1ba9",
19
+ "file_count": 1543,
20
+ "tree_sha256": "253f8de922c6d8cc2023e6a2d28b363fafa2cfdec666f96afc3a0e5d879a918f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -37,7 +37,7 @@
37
37
  {
38
38
  "path": ".claude/skills/builder-box/SKILL.md",
39
39
  "mode": "0000644",
40
- "sha256": "d8bfc9298bbb136742147e83d2500b9cb0961fec35ca15dc3cb773c9ecdf8b4b"
40
+ "sha256": "e131788fccfd87e882ef4e03798513cf7ff41047a2908fca8cf6314ff12e0524"
41
41
  },
42
42
  {
43
43
  "path": ".claude/skills/builder-claim/SKILL.md",
@@ -1572,7 +1572,7 @@
1572
1572
  {
1573
1573
  "path": "docs/recipes/ops-gotchas.md",
1574
1574
  "mode": "0000644",
1575
- "sha256": "ddbb6d4e0569a6773b648a6fe97abb2e2e04c6e36a5f3100af0cd995688a4810"
1575
+ "sha256": "1e35b15c1f366aae52aedbd12e4a193343f8fd13b2fc6bdec13f927b7a22e5f4"
1576
1576
  },
1577
1577
  {
1578
1578
  "path": "docs/recipes/overnight-builder.md",
@@ -3412,7 +3412,7 @@
3412
3412
  {
3413
3413
  "path": "modules/lifecycle/github-push.js",
3414
3414
  "mode": "0000644",
3415
- "sha256": "f090fbe484a1c9e8bf40d2871a150cef8ce73199455e54e44d117ce1ebc21da5"
3415
+ "sha256": "1a7ef193f9f2829668f5dd612972e6cca43fd84dec1e13d2b1c20f7fd99766f9"
3416
3416
  },
3417
3417
  {
3418
3418
  "path": "modules/lifecycle/goal-advisory.js",
@@ -3467,7 +3467,7 @@
3467
3467
  {
3468
3468
  "path": "modules/lifecycle/publish-reconciler.js",
3469
3469
  "mode": "0000644",
3470
- "sha256": "54fd7ba3024169f5887fe58182ba03519e66acbbf28c1b753712dbd36f91da01"
3470
+ "sha256": "6e41bbab9f8277a13178e4f9fcd278b47b8b34421114522ac113e57aedb631ac"
3471
3471
  },
3472
3472
  {
3473
3473
  "path": "modules/lifecycle/routes/analytics.js",
@@ -4057,12 +4057,12 @@
4057
4057
  {
4058
4058
  "path": "package-lock.json",
4059
4059
  "mode": "0000644",
4060
- "sha256": "b7e3e581321a968a2978affc8766e5bc9ec8f412f0c3e7a94c47916138724de9"
4060
+ "sha256": "b41abda8346dddd1c07e48067f2d9d0127aaad506519710b08cea5373a993b98"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "package.json",
4064
4064
  "mode": "0000644",
4065
- "sha256": "283e5a12a702be6937db0eb28a69775e3cb6b760ae217b3824d65ba7feb6743b"
4065
+ "sha256": "0ed0a2fa31ababaeef64de35d41cba12b4308fef35fe2d831a0532ac503581c8"
4066
4066
  },
4067
4067
  {
4068
4068
  "path": "public-docs/index.html",
@@ -4552,7 +4552,7 @@
4552
4552
  {
4553
4553
  "path": "scripts/gds/box-sync.js",
4554
4554
  "mode": "0000644",
4555
- "sha256": "8169a34dcef766360d10c7d27c7404a776aeb0b8a43f8b91fc9bc12af35a8b1b"
4555
+ "sha256": "981544bfd56d486c2017edd744c86089659771a74399b4780455821c45e3b3a9"
4556
4556
  },
4557
4557
  {
4558
4558
  "path": "scripts/gds/box.js",
@@ -5687,7 +5687,7 @@
5687
5687
  {
5688
5688
  "path": "src/module-api.js",
5689
5689
  "mode": "0000644",
5690
- "sha256": "e85465ed2f13494396cf2d74bad0c8a2b6e52d42d48c493a3d0dcf4694f6072e"
5690
+ "sha256": "793ab7f173efc8c4137e658cae4617f09a9c0df1e1783cd28396337eac6aa83b"
5691
5691
  },
5692
5692
  {
5693
5693
  "path": "src/module-loader/loader.js",
@@ -6029,6 +6029,11 @@
6029
6029
  "mode": "0000644",
6030
6030
  "sha256": "93bc891850e77350161214749954ddb257169ae323b7242f35becc4e6a9cabcf"
6031
6031
  },
6032
+ {
6033
+ "path": "tests/box_sync_scope_report.mjs",
6034
+ "mode": "0000644",
6035
+ "sha256": "010525c500aa1d5133f85a1a2ea7c23e9a680b08493a6f3b78c69a5108501468"
6036
+ },
6032
6037
  {
6033
6038
  "path": "tests/box_task_scope.mjs",
6034
6039
  "mode": "0000644",
@@ -7227,7 +7232,7 @@
7227
7232
  {
7228
7233
  "path": "tests/publish_branch_route.mjs",
7229
7234
  "mode": "0000644",
7230
- "sha256": "99572892490e209835aae6479370e97b49585bfaa719c788a4d06d92407a9a7d"
7235
+ "sha256": "e3f843387deb2d2a8f2668490bc740f7291d141b2de70c1556a84cb429bdc21d"
7231
7236
  },
7232
7237
  {
7233
7238
  "path": "tests/publish_manifest.mjs",
@@ -7237,7 +7242,7 @@
7237
7242
  {
7238
7243
  "path": "tests/publish_reconciler.mjs",
7239
7244
  "mode": "0000644",
7240
- "sha256": "fe4b594292992429fe5e077491fa2916028a2f1815414f94434f83b4186b1138"
7245
+ "sha256": "ff2428b394a50abf4a593fcbfdb98afe4b56ffdbbffdc81198a102e72b480b9f"
7241
7246
  },
7242
7247
  {
7243
7248
  "path": "tests/rank_gate.mjs",
@@ -16,12 +16,18 @@ The per-builder box lifecycle (ADR 0031 §2) auto-suspends when idle (snapshot-a
16
16
 
17
17
  ## How to use
18
18
 
19
- 1. **Fetch the box + keys** (own-scoped; uses the builder's session token):
19
+ 1. **Fetch the box + keys + the resolved scope** (own-scoped; uses the builder's session token):
20
20
  ```bash
21
- node -e 'const{apiCall}=require("./scripts/gds/cli-lib");(async()=>{for(const p of ["/api/gds/box/me","/api/gds/box/ssh-key"]){const r=await apiCall("GET",p);console.log(p,r.status,JSON.stringify(r.data));}})()'
21
+ node -e 'const{apiCall}=require("./scripts/gds/cli-lib");(async()=>{for(const p of ["/api/gds/box/me","/api/gds/box/ssh-key","/api/gds/box/scope"]){const r=await apiCall("GET",p);console.log(p,r.status,JSON.stringify(r.data));}})()'
22
22
  ```
23
23
  (If the script returns "no GDS session", run `/builder-setup` first.)
24
24
 
25
+ **Always report the scope, not just the state** (task 1002731). `/box/scope` returns `{scope, modules, sparse_paths, task_scope}`, and a too-narrow box is otherwise a *silent* failure — the builder sees a working box that is quietly missing the code they need. Read it out:
26
+ - `scope: 'task'` → name the modules: *"your box holds dev-box, hall-ui — and nothing else."*
27
+ - `scope: 'base'` → **say plainly that the box has no `modules/` code at all.** With no active claim that is expected; claiming a task and running `/builder-sync` fixes it.
28
+ - `scope: 'full'` with `modules: []` → the task-1002729 fallback fired: they hold claims, but those claims' goals declare no `scope_modules`, so the box fell back to their rank. Usable, but task-scoping is not actually doing anything — setting the goal's wall would give a tighter box.
29
+ - `scope: 'starter'` → the fixed newcomer slice, not scoped to their claim.
30
+
25
31
  2. **Relay it in plain language:**
26
32
  - **state = `none`** → they have no box yet. On a Mac/Windows: run `/builder-connect` (registers their SSH key + installs the Desktop file). To actually create the box hands-off, `POST /api/gds/box/ensure` queues the auto-provision (no operator CLI needed). *(The old path — ask an operator to `box.js onboard <login>` — still works but is no longer required.)*
27
33
  - **state = `active`** → box is up at `hostname` (`ip`). They can connect from Claude Desktop → Code → Environment → "Example Dev Box". For a browser/Chromebook (ADR 0038), the terminal comes from the web: `GET /api/gds/box/terminal` returns a `url` + a basic-auth `credential` (username `otb`, password = <redacted>; open the url, enter them, then type `claude` in the terminal. No SSH needed.
@@ -187,6 +187,17 @@ Organized by topic, not chronology. New entries go in the matching section; crea
187
187
  - **Do NOT** use `POST /tasks/:id/confirm` to unstick *another* builder's task — it credits + attributes to the *caller* (the Archon), not the original builder. `/confirm` is for the shipping builder's own `--skip-grade` path. The override-request → approve flow is the correct tool when the work was someone else's.
188
188
  - **Why it bites:** the grader defaults to fail (by design — it's adversarial), so a false-negative looks identical to a real rejection. The fix is a documented, audited human-in-the-loop appeal, not a grader bypass — and the appeal pays the right person.
189
189
 
190
+ ### A task sits at `confirmed` and every sweep logs `pr_not_landed` for a PR GitHub says is MERGED
191
+
192
+ - **Symptom:** `journalctl -u <instance>.service | grep publish-reconciler` shows the same task in the `dispositions:` line sweep after sweep as `pr_not_landed`, while `gh pr view <n>` reports `MERGED` with green checks. Only a manual `POST /api/gds/tasks/<id>/ship` clears it. Observed live 2026-08-12 (task 1002695 / PR 91, 18 min across 4 sweeps).
193
+ - **Read the disposition first.** Since task 1002724 the line carries the probe detail — `1002695:pr_not_landed(deploy=unavailable http=403 pr=91 …)` — so you can tell the three cases apart without a shell:
194
+ - `deploy=pending` — a real deploy is in flight. Correct; wait.
195
+ - `deploy=unavailable http=403` — **the server's GitHub token cannot read Actions.** The probe is blind, not waiting. The sweep now falls back to the git-history land proof and lands the task anyway (a blind probe must not outrank main's history), but the underlying token permission is still worth fixing: grant the App/PAT `actions:read` so the deploy gate is real again. On a repo with no `deploy-prod.yml` at all, a token that CAN read Actions gets a clean 404 → `unconfigured` → the merge is the land.
196
+ - `deploy=unconfigured` — no `deploy-prod.yml`; the merge IS the land (ADR 0108 pin-bump publishing).
197
+ - **Why it used to strand forever:** `getPublishStatus` folded every non-404 error into `pending` on the comment "5xx/403/rate limit is transient". A permissions 403 never resolves, and a MERGED PR at `pending` made the reconciler treat the task as mid-deploy, which *suppressed* the per-task land proof on every sweep. The blindness silently outranked proof that the work was already on `main`.
198
+ - **Related:** publish-on-merge (ADR 0161) pushes a `release: core X.Y.Z` carrier commit after every land, so on an instance that DOES deploy, the deploy run belongs to the carrier's sha, not the merge commit's. A strict sha match never hit — hence the same permanent `pending`. The probe now also accepts a successful `main` deploy that started after the merge.
199
+ - **The wait is bounded.** A probe stuck at `deploy=pending` for a reason that never resolves looks identical to a slow deploy on any single sweep, so after `<redacted>` (default 30 min) a task whose land is *provable on main* ships anyway, and the sweep logs `deploy still unconfirmed after Nmin`. This lifts only the deploy gate — the per-task land proof is untouched, so it can never become a branch-reuse false-ship. Raise the env var on an instance whose real deploy legitimately takes longer than 30 minutes.
200
+
190
201
  ---
191
202
 
192
203
  ## Status page + monitoring
@@ -382,9 +382,13 @@ async function enableAutoMerge({ nodeId }, deps = {}) {
382
382
 
383
383
  // getPublishStatus — report the PR + prod-deploy state so ship.js can poll
384
384
  // WITHOUT gh on the box. Returns:
385
- // { pr_state, merged, merge_sha, deploy_status } where
386
- // pr_state ∈ open|closed|none, deploy_status ∈ pending|success|none|unconfigured
387
- // ('unconfigured' = this repo has no deploy-prod.yml — task 1002472).
385
+ // { pr_state, merged, merge_sha, deploy_status, deploy_probe } where
386
+ // pr_state ∈ open|closed|none,
387
+ // deploy_status ∈ pending|success|none|unconfigured|unavailable
388
+ // ('unconfigured' = this repo has no deploy-prod.yml — task 1002472;
389
+ // 'unavailable' = we could not READ the deploy state at all — task 1002724).
390
+ // deploy_probe = { state, http, detail } — WHY the probe landed where it did,
391
+ // so a stuck disposition is diagnosable without a shell on the host.
388
392
  // Mirrors ship.js pollCiDeploy's two-stage check (PR merged -> deploy run).
389
393
  async function getPublishStatus({ branch }, deps = {}) {
390
394
  const token = await resolveToken(deps);
@@ -421,6 +425,7 @@ async function getPublishStatus({ branch }, deps = {}) {
421
425
 
422
426
  // Merged → did the prod-deploy workflow succeed for the merge commit?
423
427
  let deployStatus = 'pending';
428
+ let deployProbe = { state: 'pending', http: null, detail: 'no matching deploy run yet' };
424
429
  try {
425
430
  const runsRes = await doFetch(
426
431
  `${api}/actions/workflows/deploy-prod.yml/runs?branch=main&per_page=15`,
@@ -429,10 +434,29 @@ async function getPublishStatus({ branch }, deps = {}) {
429
434
  if (runsRes.ok) {
430
435
  const data = await runsRes.json();
431
436
  const runs = (data && data.workflow_runs) || [];
432
- const hit = runs.some(
433
- (r) => r && r.head_sha === mergeSha && r.status === 'completed' && r.conclusion === 'success'
434
- );
435
- if (hit) deployStatus = 'success';
437
+ const ok = (r) => r && r.status === 'completed' && r.conclusion === 'success';
438
+ // task 1002724 (idea 1000702) — the ADR 0161 CARRIER race. Publish-on-merge
439
+ // pushes a "release: core X.Y.Z" carrier commit to main after EVERY land, so
440
+ // the deploy run that actually deploys this merge is keyed to the CARRIER's
441
+ // head_sha, not the merge commit's. A strict head_sha === mergeSha match then
442
+ // never hits, deploy_status stays 'pending' forever, and the reconciler treats
443
+ // a fully-landed task as mid-deploy for good. Accept either: the run FOR this
444
+ // merge, or any successful main deploy that started after the merge landed —
445
+ // deploying main after the merge necessarily deploys the merge.
446
+ const mergedAtMs = pr.merged_at ? Date.parse(pr.merged_at) : NaN;
447
+ const exact = runs.some((r) => ok(r) && r.head_sha === mergeSha);
448
+ const afterMerge = !exact && Number.isFinite(mergedAtMs) && runs.some((r) => {
449
+ if (!ok(r)) return false;
450
+ const startedMs = Date.parse(r.run_started_at || r.created_at || '');
451
+ return Number.isFinite(startedMs) && startedMs >= mergedAtMs;
452
+ });
453
+ if (exact || afterMerge) {
454
+ deployStatus = 'success';
455
+ deployProbe = {
456
+ state: 'success', http: runsRes.status,
457
+ detail: exact ? 'deploy run for the merge commit' : 'main deploy succeeded after the merge (ADR 0161 carrier)',
458
+ };
459
+ }
436
460
  } else if (runsRes.status === 404) {
437
461
  // task 1002472 — the workflow FILE does not exist in this repo, so there is no
438
462
  // deploy to wait for and 'pending' would never resolve. The 404 is unambiguous:
@@ -442,12 +466,30 @@ async function getPublishStatus({ branch }, deps = {}) {
442
466
  // (package-core → bongos upgrade, ADR 0108) never have a deploy-prod.yml at all;
443
467
  // reporting 'pending' pinned every merged PR at confirmed forever.
444
468
  deployStatus = 'unconfigured';
469
+ deployProbe = { state: 'unconfigured', http: 404, detail: 'no deploy-prod.yml in this repo' };
470
+ } else {
471
+ // task 1002724 — this branch used to fall through as 'pending' on the comment
472
+ // "5xx/403/rate-limit is transient". A 403 from a token without `actions:read`
473
+ // is NOT transient, it is forever — and 'pending' on a MERGED PR makes the
474
+ // reconciler suppress the land proof on every sweep (prMidDeploy), so the task
475
+ // strands at 'confirmed' permanently with a bare 'pr_not_landed' and no clue
476
+ // why. That is exactly the live 2026-08-12 strand (task 1002695 / PR 91:
477
+ // GitHub reported MERGED with green checks, the server reported deploy
478
+ // 'pending' for 18 min across 4 sweeps, and only a manual ship cleared it).
479
+ // Call it what it is — we could not READ the deploy state — and let the
480
+ // reconciler decide whether git history already proves the land.
481
+ deployStatus = 'unavailable';
482
+ deployProbe = { state: 'unavailable', http: runsRes.status, detail: `workflow-runs read failed (HTTP ${runsRes.status})` };
445
483
  }
446
- // Any other non-ok (5xx, 403, rate limit) is transient → stay 'pending' and retry.
447
- } catch (_) {
448
- /* leave pending on a transient runs-API miss */
484
+ } catch (e) {
485
+ // A network-level miss is genuinely transient — keep waiting, but say so.
486
+ deployProbe = { state: 'pending', http: null, detail: `workflow-runs fetch threw: ${e && e.message}` };
449
487
  }
450
- return { pr_state: pr.state, merged: true, merge_sha: mergeSha, deploy_status: deployStatus, pr_url: pr.html_url, ...prMeta };
488
+ return {
489
+ pr_state: pr.state, merged: true, merge_sha: mergeSha,
490
+ deploy_status: deployStatus, deploy_probe: deployProbe,
491
+ pr_url: pr.html_url, ...prMeta,
492
+ };
451
493
  }
452
494
 
453
495
  // task 1547 — does the PR matched by getPublishStatus actually belong to THIS task?
@@ -755,11 +797,23 @@ async function setCommitStatus({ sha, state, context, description, targetUrl },
755
797
  // is the land, and requiring a deploy that can never run pinned every merged PR at
756
798
  // 'confirmed' forever. MERGED stays mandatory, and this is only the gate on the
757
799
  // branch/PR path — landProvenForTask still has to prove the land is THIS task's.
800
+ // task 1002724 — 'unavailable' deliberately does NOT count as landed here. On an
801
+ // instance that really does deploy, a blind probe must not be promoted into
802
+ // "deployed". It is the RECONCILER that treats blindness differently from a real
803
+ // in-flight deploy (see deployProbeBlind): a probe that cannot see anything must
804
+ // not veto a positive git-history proof of the land.
758
805
  function landConfirmed(status) {
759
806
  if (!status || status.merged !== true) return false;
760
807
  return status.deploy_status === 'success' || status.deploy_status === 'unconfigured';
761
808
  }
762
809
 
810
+ // Is the deploy probe BLIND (we could not read the deploy state) as opposed to
811
+ // legitimately waiting on a deploy we can see? Exported so the reconciler and the
812
+ // publish-status route agree on the distinction.
813
+ function deployProbeBlind(status) {
814
+ return !!status && status.deploy_status === 'unavailable';
815
+ }
816
+
763
817
  // task 1547 — is a landConfirmed status PROVEN to be THIS task's land, and not a
764
818
  // reused worktree branch matching an OLDER task's already-merged PR (the BV1.R04/R05
765
819
  // false-ship: branch claude/jovial-rosalind-84d6c3 → PR #386 / task 1297, merged,
@@ -1090,6 +1144,7 @@ module.exports = {
1090
1144
  recheckPullRequest,
1091
1145
  GATE_OWNER_APPROVED_CONTEXT,
1092
1146
  landConfirmed,
1147
+ deployProbeBlind,
1093
1148
  landProvenForTask,
1094
1149
  prReferencesTask,
1095
1150
  findTaskPullRequest,
@@ -96,6 +96,18 @@ const SWEEP_LIMIT = 100;
96
96
  // landed and that is OLDER than this is a strand — credited but with no provable
97
97
  // land — and gets a WARN. Default 30 min; tune/disable via env.
98
98
  const STALE_CONFIRMED_MS = Number(process.env.PUBLISH_RECONCILE_STALE_MS) || 30 * 60_000;
99
+
100
+ // task 1002724 — how long a MERGED PR may claim "still deploying" before the sweep
101
+ // stops letting that claim veto a git-history-proven land.
102
+ //
103
+ // The blindness fix below handles the case where the probe REPORTS it cannot read
104
+ // the deploy state ('unavailable'). It cannot handle a probe that keeps answering
105
+ // 'pending' for a reason that never resolves — e.g. the workflow-runs fetch throwing
106
+ // every sweep, which is indistinguishable from a slow deploy on any single pass. So
107
+ // bound the wait: past this age, a task whose land is PROVABLE on main ships, and the
108
+ // sweep says loudly that it stopped waiting. Anything unprovable is untouched — this
109
+ // relaxes only the deploy gate, never the per-task land proof.
110
+ const DEPLOY_WAIT_BOUND_MS = Number(process.env.PUBLISH_RECONCILE_DEPLOY_WAIT_MS) || 30 * 60_000;
99
111
  // task 1915: cap the speculative regen-heal per branch so a genuine treadmill (main
100
112
  // churning faster than CI clears a re-freshened branch) can't loop forever. In-memory
101
113
  // per process — a restart resets the guard, which is fine (heals are idempotent and
@@ -206,6 +218,18 @@ async function runSweep(deps = {}) {
206
218
  wtCount.set(r.worktree_name, (wtCount.get(r.worktree_name) || 0) + 1);
207
219
  }
208
220
 
221
+ // task 1002724 — a one-line, log-safe summary of WHY the deploy probe landed
222
+ // where it did, so 'pr_not_landed' in a journal line is self-explaining.
223
+ const describeProbe = (status) => {
224
+ if (!status) return 'no status';
225
+ const p = status.deploy_probe || null;
226
+ const bits = [`deploy=${status.deploy_status ?? 'unknown'}`];
227
+ if (p && p.http) bits.push(`http=${p.http}`);
228
+ if (status.pr_number) bits.push(`pr=${status.pr_number}`);
229
+ if (p && p.detail) bits.push(String(p.detail).slice(0, 80));
230
+ return bits.join(' ');
231
+ };
232
+
209
233
  let reconciled = 0;
210
234
  let merged = 0;
211
235
  let resolvedConflicts = 0;
@@ -545,7 +569,30 @@ async function runSweep(deps = {}) {
545
569
  // task 1002615: only a MERGED PR that is still deploying suppresses the proof
546
570
  // (the deploy gate governs); an un-merged PR — open, or closed without merging —
547
571
  // cannot veto what git history on main already shows.
548
- const prMidDeploy = prNotLanded && !!(branchStatus && branchStatus.merged === true);
572
+ // task 1002724 — a MERGED PR still deploying may veto the land proof, but only
573
+ // while we can actually SEE the deploy. When the probe is blind (deploy_status
574
+ // 'unavailable' — e.g. a token without `actions:read` 403s, permanently), the
575
+ // old code called that "mid-deploy" and suppressed the proof on every sweep
576
+ // forever: the task stranded at 'confirmed' even though main's git history
577
+ // already proved the land. Blindness must not outrank evidence.
578
+ const probeBlind = typeof push.deployProbeBlind === 'function'
579
+ ? push.deployProbeBlind(branchStatus)
580
+ : false;
581
+ // …and a deploy we can "see" but that never resolves is the same trap wearing a
582
+ // different hat, so the wait is bounded too (DEPLOY_WAIT_BOUND_MS). Both escapes
583
+ // only lift the DEPLOY gate — landProvenForTask below still has to prove the land
584
+ // belongs to this task, so neither can cause a branch-reuse false-ship.
585
+ const confirmedAgeMs = r.confirmed_at ? (nowMs - new Date(r.confirmed_at).getTime()) : null;
586
+ const deployWaitExpired = confirmedAgeMs != null && confirmedAgeMs > DEPLOY_WAIT_BOUND_MS;
587
+ const prMidDeploy = prNotLanded
588
+ && !!(branchStatus && branchStatus.merged === true)
589
+ && !probeBlind
590
+ && !deployWaitExpired;
591
+ if (prNotLanded && !!(branchStatus && branchStatus.merged === true) && (probeBlind || deployWaitExpired)) {
592
+ logFn(
593
+ `[publish-reconciler] task ${r.task_id}: ${probeBlind ? 'deploy probe BLIND' : `deploy still unconfirmed after ${Math.round(confirmedAgeMs / 60_000)}min`} (${describeProbe(branchStatus)}) — consulting the git-history land proof instead of waiting on a deploy state that is not resolving`
594
+ );
595
+ }
549
596
  if (!landed && !prMidDeploy && typeof push.landProvenForTask === 'function') {
550
597
  try {
551
598
  const proof = await push.landProvenForTask({
@@ -583,7 +630,11 @@ async function runSweep(deps = {}) {
583
630
  : prNotLanded
584
631
  ? 'pr_not_landed'
585
632
  : (!branch && !r.published_head_sha) ? 'no_branch_no_tip' : 'land_not_proven';
586
- dispositions.push(`${r.task_id}:${reason}`);
633
+ // task 1002724 — a bare 'pr_not_landed' says a PR exists and did not land, but
634
+ // not WHICH probe refused or why, so the 2026-08-12 strand needed a host shell
635
+ // and a hand-run of getPublishStatus to explain. Carry the probe detail into
636
+ // the disposition so the log line alone is diagnosable.
637
+ dispositions.push(`${r.task_id}:${reason}${reason === 'pr_not_landed' ? `(${describeProbe(branchStatus)})` : ''}`);
587
638
  const ageMs = r.confirmed_at ? (nowMs - new Date(r.confirmed_at).getTime()) : null;
588
639
  if (ageMs != null && ageMs > STALE_CONFIRMED_MS) {
589
640
  staleStrands++;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.80",
3
+ "version": "1.19.82",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.80",
9
+ "version": "1.19.82",
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.19.80",
3
+ "version": "1.19.82",
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",
@@ -57,10 +57,77 @@ function main(argv = process.argv.slice(2)) {
57
57
  console.error(`sync: could not run the fetch (${r.error.message})`);
58
58
  process.exit(1);
59
59
  }
60
- if (r.status === 0) console.log('sync: done — your box now holds the code for your active claim.');
61
- process.exit(r.status == null ? 1 : r.status);
60
+ if (r.status !== 0) process.exit(r.status == null ? 1 : r.status);
61
+ console.log('sync: done — your box now holds the code for your active claim.');
62
+ // Then say WHAT it holds, so a base-only box is visible instead of silent.
63
+ reportScope().then(() => process.exit(0), () => process.exit(0));
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // task 1002731 — say what scope the box ACTUALLY got
68
+ // ---------------------------------------------------------------------------
69
+
70
+ // The failure this exists for is a SILENT one: a box comes up holding only the
71
+ // base slice (no modules/ at all) and nothing anywhere says so. Live, that was
72
+ // 247 of 248 source grants — including an Archon with 7 active claims — and the
73
+ // only way to see it was reading box_events on the control plane. GET /box/scope
74
+ // already returns the resolved decision; nothing ever showed it to a human.
75
+ //
76
+ // PURE so it is unit-testable without a box or a network: takes the /box/scope
77
+ // body, returns the lines to print. null/garbage in → one honest line rather
78
+ // than a crash, because this is diagnostic output on an already-degraded path
79
+ // and must never be the thing that fails.
80
+ function formatScopeReport(scope) {
81
+ if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
82
+ return ['scope: could not read your box scope (GET /box/scope did not answer).'];
83
+ }
84
+ const modules = Array.isArray(scope.modules) ? scope.modules : null;
85
+ const paths = Array.isArray(scope.sparse_paths) ? scope.sparse_paths : [];
86
+ const out = [];
87
+
88
+ if (scope.scope === 'task' && modules && modules.length) {
89
+ out.push(`scope: task — your box holds ${modules.length} module(s): ${modules.join(', ')}`);
90
+ } else if (scope.scope === 'base') {
91
+ out.push('scope: BASE ONLY — your box holds no modules/ code at all.');
92
+ out.push(scope.task_scope
93
+ ? ' Why: you hold no active claim, so there is no work to scope the box to.'
94
+ : ' Why: this instance resolved no claim scope.');
95
+ out.push(' Fix: claim a task (/builder-claim N), then re-run this sync.');
96
+ } else if (scope.scope === 'full') {
97
+ out.push('scope: full — your box holds the whole repo (your rank allows it).');
98
+ if (modules && modules.length === 0) {
99
+ // The task-1002729 fallback fired: claims held, but their goals declare no
100
+ // wall. Worth naming — the box is usable, but task-scoping is doing nothing.
101
+ out.push(' Note: your claimed work declares no module scope, so this fell back to');
102
+ out.push(" your rank rather than scoping to the task. Setting the goal's");
103
+ out.push(' scope_modules would give you a tighter box.');
104
+ }
105
+ } else if (scope.scope === 'starter') {
106
+ out.push('scope: starter — a fixed slice for newcomers, not scoped to your claim.');
107
+ } else {
108
+ out.push(`scope: ${String(scope.scope || 'unknown')}`);
109
+ }
110
+ if (paths.length) out.push(` paths: ${paths.length} sparse entr${paths.length === 1 ? 'y' : 'ies'}`);
111
+ return out;
112
+ }
113
+
114
+ // Read the resolved scope and print it. Never throws and never changes the exit
115
+ // status — a diagnostic that could fail the sync it reports on would be worse
116
+ // than no diagnostic.
117
+ async function reportScope() {
118
+ try {
119
+ const { apiCall } = require('./cli-lib');
120
+ const r = await apiCall('GET', '/api/gds/box/scope');
121
+ if (!r || r.status !== 200) {
122
+ console.log('scope: could not read your box scope right now (that does not affect the sync above).');
123
+ return;
124
+ }
125
+ for (const line of formatScopeReport(r.data)) console.log(line);
126
+ } catch (_) {
127
+ console.log('scope: could not read your box scope right now (that does not affect the sync above).');
128
+ }
62
129
  }
63
130
 
64
131
  if (require.main === module) main();
65
132
 
66
- module.exports = { resolveFetchScript, FETCH_SCRIPT };
133
+ module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport };
package/src/module-api.js CHANGED
@@ -587,8 +587,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
587
587
  // landed since 1.19.78 with no explicit bump. run 31648954641. (task 1002620)
588
588
  // 1.19.80 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
589
589
  // landed since 1.19.79 with no explicit bump. run 31649366675. (task 1002620)
590
+ // 1.19.81 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
591
+ // landed since 1.19.80 with no explicit bump. run 31649705242. (task 1002620)
592
+ // 1.19.82 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
593
+ // landed since 1.19.81 with no explicit bump. run 31650108283. (task 1002620)
590
594
  // ---------------------------------------------------------------------------
591
- const CORE_VERSION = '1.19.80'; // CI auto-patch carrier (ADR 0161); see changelog above
595
+ const CORE_VERSION = '1.19.82'; // CI auto-patch carrier (ADR 0161); see changelog above
592
596
 
593
597
  // A namespaced logger so a module's log lines are attributable + consistent.
594
598
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,81 @@
1
+ // tests/box_sync_scope_report.mjs
2
+ //
3
+ // Task 1002731 — the scope read-out that makes a too-narrow box VISIBLE.
4
+ //
5
+ // The failure being guarded is a silent one: a box comes up holding only the
6
+ // base slice (no modules/ at all) and nothing tells the builder. Live, that was
7
+ // 247 of 248 source grants on the cloudbongos fleet — including an Archon
8
+ // holding 7 active claims — and the only way to see it was reading box_events
9
+ // on the control plane.
10
+ //
11
+ // formatScopeReport is PURE (takes the GET /box/scope body, returns lines), so
12
+ // this needs no box, no network and no DB.
13
+ //
14
+ // Run: node tests/box_sync_scope_report.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { formatScopeReport } = require('../scripts/gds/box-sync.js');
21
+
22
+ let passed = 0;
23
+ let failed = 0;
24
+ function t(name, fn) {
25
+ try { fn(); passed++; console.log(` PASS ${name}`); }
26
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
27
+ }
28
+ const joined = (scope) => formatScopeReport(scope).join('\n');
29
+
30
+ t('base-only is stated in plain words, with a why and a fix', () => {
31
+ const out = joined({ scope: 'base', mode: 'sparse', modules: [], sparse_paths: ['docs', 'scripts'], task_scope: true });
32
+ assert.match(out, /BASE ONLY/, 'the degraded state is named, not buried');
33
+ assert.match(out, /no modules\/ code at all/, 'says what is actually missing');
34
+ assert.match(out, /Why:/, 'explains the cause');
35
+ assert.match(out, /builder-claim/, 'gives the builder the actual next step');
36
+ });
37
+
38
+ t('task scope names the modules the box holds', () => {
39
+ const out = joined({ scope: 'task', mode: 'sparse', modules: ['dev-box', 'hall-ui'], sparse_paths: ['a', 'b', 'c'], task_scope: true });
40
+ assert.match(out, /scope: task/);
41
+ assert.match(out, /dev-box, hall-ui/, 'names them rather than just counting');
42
+ assert.match(out, /3 sparse entries/);
43
+ });
44
+
45
+ t('full + EMPTY modules surfaces the 1002729 fallback rather than looking healthy', () => {
46
+ // The exact live shape: claims held, but their goals declare no scope_modules.
47
+ // The box works, so nothing complains — but task-scoping is not doing anything,
48
+ // and that is worth telling someone.
49
+ const out = joined({ scope: 'full', mode: 'full', modules: [], sparse_paths: null, task_scope: true });
50
+ assert.match(out, /scope: full/);
51
+ assert.match(out, /declares no module scope/, 'names why it fell back');
52
+ assert.match(out, /scope_modules/, 'points at the thing to fix');
53
+ });
54
+
55
+ t('full with real modules does NOT nag about the fallback', () => {
56
+ const out = joined({ scope: 'full', mode: 'full', modules: ['kernel'], sparse_paths: null, task_scope: false });
57
+ assert.match(out, /scope: full/);
58
+ assert.ok(!/declares no module scope/.test(out), 'no false alarm when nothing is wrong');
59
+ });
60
+
61
+ t('starter is distinguished from a claim-scoped box', () => {
62
+ const out = joined({ scope: 'starter', mode: 'sparse', modules: null, sparse_paths: ['x'], task_scope: false });
63
+ assert.match(out, /starter/);
64
+ assert.match(out, /not scoped to your claim/);
65
+ });
66
+
67
+ t('a missing or malformed body degrades to one honest line, never a throw', () => {
68
+ // This runs on an already-degraded path; it must not be the thing that fails.
69
+ for (const bad of [null, undefined, 'nope', 42, []]) {
70
+ const lines = formatScopeReport(bad);
71
+ assert.ok(Array.isArray(lines) && lines.length >= 1, `handled ${JSON.stringify(bad)}`);
72
+ }
73
+ assert.match(joined(null), /could not read/);
74
+ });
75
+
76
+ t('an unknown scope value is reported verbatim rather than swallowed', () => {
77
+ assert.match(joined({ scope: 'something-new', sparse_paths: [] }), /something-new/);
78
+ });
79
+
80
+ console.log(`\nbox_sync_scope_report.mjs: ${passed} passed, ${failed} failed`);
81
+ process.exit(failed === 0 ? 0 : 1);
@@ -242,7 +242,11 @@ await t('getPublishStatus: merged + workflow-runs 404 → unconfigured (no deplo
242
242
  assert.equal(s.deploy_status, 'unconfigured', 'an absent workflow is permanent, not pending');
243
243
  });
244
244
 
245
- await t('getPublishStatus: merged + workflow-runs 5xx/403 → still pending (transient stays transient)', async () => {
245
+ // task 1002724 — an unreadable deploy state is its OWN thing: not "no workflow"
246
+ // (which would land it) and not "a deploy is running" (which vetoes the land proof
247
+ // forever). A 403 from a token without `actions:read` never resolves, and calling
248
+ // it 'pending' stranded task 1002695 at confirmed across 4 sweeps on 2026-08-12.
249
+ await t('getPublishStatus: merged + workflow-runs 5xx/403 → unavailable, never landConfirmed', async () => {
246
250
  const mergeSha = 'e'.repeat(40);
247
251
  const mk = (code) => async (url) => {
248
252
  if (url.includes('/pulls?head=')) return jsonRes(200, [{ state: 'closed', merged_at: '2026-07-26T00:00:00Z', merge_commit_sha: mergeSha, html_url: 'http://pr/1' }]);
@@ -251,10 +255,67 @@ await t('getPublishStatus: merged + workflow-runs 5xx/403 → still pending (tra
251
255
  };
252
256
  for (const code of [500, 502, 403]) {
253
257
  const s = await gp.getPublishStatus({ branch: 'claude/x' }, { token: 'TT', repoInfo: REPO, fetchImpl: mk(code) });
254
- assert.equal(s.deploy_status, 'pending', `${code} must NOT be read as "no workflow"`);
258
+ assert.equal(s.deploy_status, 'unavailable', `${code} must NOT be read as "no workflow" nor as a running deploy`);
259
+ assert.equal(s.deploy_probe.http, code, 'the probe must carry the HTTP status for diagnosis');
260
+ assert.equal(gp.landConfirmed(s), false, 'a blind probe must never be promoted into "deployed"');
261
+ assert.equal(gp.deployProbeBlind(s), true);
255
262
  }
256
263
  });
257
264
 
265
+ await t('getPublishStatus: a network throw stays pending (genuinely transient), not unavailable', async () => {
266
+ const mergeSha = 'e'.repeat(40);
267
+ const fetchImpl = async (url) => {
268
+ if (url.includes('/pulls?head=')) return jsonRes(200, [{ state: 'closed', merged_at: '2026-07-26T00:00:00Z', merge_commit_sha: mergeSha, html_url: 'http://pr/1' }]);
269
+ if (url.includes('deploy-prod.yml/runs')) throw new Error('ECONNRESET');
270
+ throw new Error('unexpected ' + url);
271
+ };
272
+ const s = await gp.getPublishStatus({ branch: 'claude/x' }, { token: 'TT', repoInfo: REPO, fetchImpl });
273
+ assert.equal(s.deploy_status, 'pending');
274
+ assert.equal(gp.deployProbeBlind(s), false, 'a socket blip is a retry, not blindness');
275
+ assert.match(s.deploy_probe.detail, /threw/);
276
+ });
277
+
278
+ // task 1002724 / idea 1000702 — the ADR 0161 carrier race. Publish-on-merge pushes
279
+ // a "release: core X.Y.Z" carrier commit to main after every land, so the deploy run
280
+ // that deploys THIS merge is keyed to the carrier's sha, not the merge commit's.
281
+ await t('getPublishStatus: a successful main deploy AFTER the merge counts (ADR 0161 carrier)', async () => {
282
+ const mergeSha = 'a'.repeat(40);
283
+ const carrierSha = 'b'.repeat(40);
284
+ const fetchImpl = async (url) => {
285
+ if (url.includes('/pulls?head=')) {
286
+ return jsonRes(200, [{ state: 'closed', merged_at: '2026-08-12T20:25:23Z', merge_commit_sha: mergeSha, html_url: 'http://pr/91' }]);
287
+ }
288
+ if (url.includes('deploy-prod.yml/runs')) {
289
+ return jsonRes(200, { workflow_runs: [
290
+ // the carrier's deploy — a different head_sha, started after the merge
291
+ { head_sha: carrierSha, status: 'completed', conclusion: 'success', run_started_at: '2026-08-12T20:27:00Z' },
292
+ ] });
293
+ }
294
+ throw new Error('unexpected ' + url);
295
+ };
296
+ const s = await gp.getPublishStatus({ branch: 'claude/x' }, { token: 'TT', repoInfo: REPO, fetchImpl });
297
+ assert.equal(s.deploy_status, 'success', 'deploying main after the merge deploys the merge');
298
+ assert.equal(gp.landConfirmed(s), true);
299
+ assert.match(s.deploy_probe.detail, /carrier/i);
300
+ });
301
+
302
+ await t('getPublishStatus: a main deploy that finished BEFORE the merge does not count', async () => {
303
+ const mergeSha = 'a'.repeat(40);
304
+ const fetchImpl = async (url) => {
305
+ if (url.includes('/pulls?head=')) {
306
+ return jsonRes(200, [{ state: 'closed', merged_at: '2026-08-12T20:25:23Z', merge_commit_sha: mergeSha, html_url: 'http://pr/91' }]);
307
+ }
308
+ if (url.includes('deploy-prod.yml/runs')) {
309
+ return jsonRes(200, { workflow_runs: [
310
+ { head_sha: 'c'.repeat(40), status: 'completed', conclusion: 'success', run_started_at: '2026-08-12T20:00:00Z' },
311
+ ] });
312
+ }
313
+ throw new Error('unexpected ' + url);
314
+ };
315
+ const s = await gp.getPublishStatus({ branch: 'claude/x' }, { token: 'TT', repoInfo: REPO, fetchImpl });
316
+ assert.equal(s.deploy_status, 'pending', 'an earlier deploy cannot have carried this merge');
317
+ });
318
+
258
319
  await t('getPublishStatus: merged, workflow EXISTS but no successful run yet → pending', async () => {
259
320
  const mergeSha = 'f'.repeat(40);
260
321
  const fetchImpl = async (url) => {
@@ -129,6 +129,9 @@ function mkDeps({
129
129
  if (a instanceof Error) throw a;
130
130
  return !!a;
131
131
  },
132
+ // task 1002724: the REAL blindness predicate, so the sweep's "is this deploy
133
+ // state unreadable?" decision cannot drift from getPublishStatus's answer.
134
+ deployProbeBlind: githubPush.deployProbeBlind,
132
135
  // task 1547 per-task land proof. Mirrors the real github-push.landProvenForTask
133
136
  // shape but routes its network hops (the ancestry compares) through the mock
134
137
  // above so the sweep stays DB/network-free, and reuses the REAL prReferencesTask
@@ -333,6 +336,122 @@ test('1002472 merged + deploy unconfigured + published tip in main → ships', a
333
336
  assert.deepEqual(deps._calls.broadcast, [63]);
334
337
  });
335
338
 
339
+ // ---------------------------------------------------------------------------
340
+ // task 1002724 — the deploy probe went BLIND and vetoed a provable land forever.
341
+ //
342
+ // Live on 2026-08-12: task 1002695 / PR 91 was MERGED with green checks at
343
+ // 20:25:23Z, but the server's deploy probe answered 'pending' (a 403 from a token
344
+ // without `actions:read` — permanent, not transient). merged + !landConfirmed made
345
+ // prMidDeploy true, which SUPPRESSED landProvenForTask on every sweep, so four
346
+ // sweeps across 18 minutes all logged a bare '1002695:pr_not_landed' and only a
347
+ // manual POST /ship cleared it. A probe that cannot see anything must not outrank
348
+ // main's git history, and the disposition must say which probe refused.
349
+ // ---------------------------------------------------------------------------
350
+ const BLIND = (id) => ({
351
+ ...landedPrFor(id),
352
+ deploy_status: 'unavailable',
353
+ deploy_probe: { state: 'unavailable', http: 403, detail: 'workflow-runs read failed (HTTP 403)' },
354
+ merge_sha: MERGE_SHA,
355
+ });
356
+
357
+ test('1002724 blind deploy probe no longer vetoes a tip-proven land', async () => {
358
+ const tip = 'd'.repeat(40);
359
+ const deps = mkDeps({
360
+ rows: [{ task_id: 1002695, builder_id: 9, worktree_name: 'wt-blind', published_head_sha: tip,
361
+ confirmed_at: new Date().toISOString() }],
362
+ statusByBranch: { 'claude/wt-blind': BLIND(1002695) },
363
+ ancestryBySha: { [tip]: true },
364
+ });
365
+ const r = await reconciler.runSweep(deps);
366
+ assert.equal(r.reconciled, 1, 'the published tip is in main — that IS the land');
367
+ assert.deepEqual(deps._calls.ship, [{ taskId: 1002695, builderId: 9 }]);
368
+ });
369
+
370
+ test('1002724 blind probe + merged PR naming the task → the merge-commit proof still lands it', async () => {
371
+ const deps = mkDeps({
372
+ rows: [{ task_id: 1002695, builder_id: 9, worktree_name: 'wt-blind2', published_head_sha: null,
373
+ confirmed_at: new Date().toISOString() }],
374
+ statusByBranch: { 'claude/wt-blind2': BLIND(1002695) },
375
+ ancestryBySha: { [MERGE_SHA]: true },
376
+ });
377
+ const r = await reconciler.runSweep(deps);
378
+ assert.equal(r.reconciled, 1, 'a merged PR naming this task whose merge sha is in main is a land');
379
+ });
380
+
381
+ test('1002724 a REAL in-flight deploy still vetoes the proof (the gate is not weakened)', async () => {
382
+ const tip = 'e'.repeat(40);
383
+ const deps = mkDeps({
384
+ rows: [{ task_id: 65, builder_id: 4, worktree_name: 'wt-realdeploy', published_head_sha: tip,
385
+ confirmed_at: new Date().toISOString() }],
386
+ // deploy_status 'pending' = we CAN read the deploy state and it has not succeeded.
387
+ statusByBranch: { 'claude/wt-realdeploy': { ...landedPrFor(65), deploy_status: 'pending' } },
388
+ ancestryBySha: { [tip]: true },
389
+ });
390
+ const r = await reconciler.runSweep(deps);
391
+ assert.equal(r.reconciled, 0, 'merged-but-still-deploying must keep waiting — shipped means deployed');
392
+ assert.equal(deps._calls.ship.length, 0);
393
+ });
394
+
395
+ test('1002724 a deploy that never resolves stops vetoing a provable land once the wait is bounded', async () => {
396
+ // The blindness escape only fires when the probe SAYS it is blind. A probe that
397
+ // keeps answering 'pending' forever (e.g. the runs fetch throwing every sweep)
398
+ // looks identical to a slow deploy on any single pass — so the wait is bounded.
399
+ const tip = '1'.repeat(40);
400
+ const old = new Date(Date.now() - 45 * 60_000).toISOString(); // 45min > the 30min bound
401
+ const deps = mkDeps({
402
+ rows: [{ task_id: 67, builder_id: 4, worktree_name: 'wt-forever', published_head_sha: tip, confirmed_at: old }],
403
+ statusByBranch: { 'claude/wt-forever': { ...landedPrFor(67), deploy_status: 'pending' } },
404
+ ancestryBySha: { [tip]: true },
405
+ });
406
+ const r = await reconciler.runSweep(deps);
407
+ assert.equal(r.reconciled, 1, 'past the bound, a provable land must not wait on a deploy forever');
408
+ assert.ok(
409
+ deps._calls.logs.some((l) => /deploy still unconfirmed after \d+min/.test(l)),
410
+ 'the sweep must say it stopped waiting, not ship silently'
411
+ );
412
+ });
413
+
414
+ test('1002724 the bound lifts only the DEPLOY gate — an unprovable land still strands', async () => {
415
+ const tip = '2'.repeat(40);
416
+ const old = new Date(Date.now() - 45 * 60_000).toISOString();
417
+ const deps = mkDeps({
418
+ rows: [{ task_id: 68, builder_id: 4, worktree_name: 'wt-forever-unproven', published_head_sha: tip, confirmed_at: old }],
419
+ statusByBranch: { 'claude/wt-forever-unproven': { ...landedPrFor(68), deploy_status: 'pending' } },
420
+ ancestryBySha: { [tip]: false }, // tip NOT in main — no land to prove
421
+ });
422
+ const r = await reconciler.runSweep(deps);
423
+ assert.equal(r.reconciled, 0, 'the bound must never become a branch-reuse false-ship');
424
+ assert.equal(deps._calls.ship.length, 0);
425
+ });
426
+
427
+ test('1002724 a young in-flight deploy is still waited on (the bound does not fire early)', async () => {
428
+ const tip = '3'.repeat(40);
429
+ const deps = mkDeps({
430
+ rows: [{ task_id: 69, builder_id: 4, worktree_name: 'wt-young', published_head_sha: tip,
431
+ confirmed_at: new Date(Date.now() - 60_000).toISOString() }], // 1min old
432
+ statusByBranch: { 'claude/wt-young': { ...landedPrFor(69), deploy_status: 'pending' } },
433
+ ancestryBySha: { [tip]: true },
434
+ });
435
+ const r = await reconciler.runSweep(deps);
436
+ assert.equal(r.reconciled, 0, 'shipped means deployed while the deploy is plausibly still running');
437
+ });
438
+
439
+ test('1002724 an unprovable pr_not_landed disposition names the failing probe', async () => {
440
+ const tip = 'f'.repeat(40);
441
+ const deps = mkDeps({
442
+ rows: [{ task_id: 66, builder_id: 4, worktree_name: 'wt-diag', published_head_sha: tip,
443
+ confirmed_at: new Date().toISOString() }],
444
+ statusByBranch: { 'claude/wt-diag': { ...landedPrFor(66), deploy_status: 'pending', pr_number: 91 } },
445
+ ancestryBySha: { [tip]: false },
446
+ });
447
+ await reconciler.runSweep(deps);
448
+ const line = deps._calls.logs.find((l) => /dispositions:/.test(l));
449
+ assert.ok(line, 'the sweep must emit a dispositions line');
450
+ assert.match(line, /66:pr_not_landed\(/, 'the disposition must carry probe detail, not be bare');
451
+ assert.match(line, /deploy=pending/);
452
+ assert.match(line, /pr=91/, 'the PR number makes the strand traceable from the log alone');
453
+ });
454
+
336
455
  test('1002472 merged + deploy unconfigured but tip NOT in main → NO ship (1547 proof intact)', async () => {
337
456
  const deps = mkDeps({
338
457
  rows: [{ task_id: 64, builder_id: 4, worktree_name: 'wt-nowf-reuse', published_head_sha: NOT_ON_MAIN }],