@bongos/core 1.19.81 → 1.19.83

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.81",
6
- "core_contract": "1.19.81",
7
- "source_commit": "c19697303b3961520ebb175928cde6beddf00896",
5
+ "core_version": "1.19.83",
6
+ "core_contract": "1.19.83",
7
+ "source_commit": "fff49f72b2b345f6b610b37c943bc45b963f2b40",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T23:08:05.391Z",
9
+ "built_at": "2026-08-12T23:29:40.091Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 295,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1227,
14
+ "functional_verbatim": 1228,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1543,
20
- "tree_sha256": "799b7337a46edabc3eb3adca6a29fb43c274e1ab27a9b130d09a986d4177cd9b",
19
+ "file_count": 1544,
20
+ "tree_sha256": "ec5d11dea2b4d13543a2593e7b1c13583eefea55e3ffca3b42cd9835cfdd134a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -707,7 +707,7 @@
707
707
  {
708
708
  "path": "docs/adr/0055-server-mediated-branch-publish.md",
709
709
  "mode": "0000644",
710
- "sha256": "a574107524d7b0c85130df49b2b165b94dc1d1b0efe1493b7952e49c053c8a53"
710
+ "sha256": "6f51c67939eacaf510774894625d0a09e6883e4c8e1d8fdea2cf35f8d9f58ea4"
711
711
  },
712
712
  {
713
713
  "path": "docs/adr/0056-prod-deploy-script-mirror-and-healthcheck-retry.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": "0534cf4522f5a644301946abf6a703852375fe269401902dfae10d7b9192c3d5"
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": "845771b9d06a05dcb4eb8b90a0002b26d1fc105c2d27d24f870f89640fb09769"
4060
+ "sha256": "4e476d4e4a1ce4f6f0065ee8a18903571b5c89783f90929d62b5b5d9b4afe385"
4061
4061
  },
4062
4062
  {
4063
4063
  "path": "package.json",
4064
4064
  "mode": "0000644",
4065
- "sha256": "04febc37475484098b13f52462cda84d2a120c5ef64b7c2edf733e14ac6b3e1f"
4065
+ "sha256": "db68efb63158fb5e544d9afc252161739f7c54c832509dc03fa45878675cb990"
4066
4066
  },
4067
4067
  {
4068
4068
  "path": "public-docs/index.html",
@@ -5039,6 +5039,11 @@
5039
5039
  "mode": "0000644",
5040
5040
  "sha256": "7ad384495370312a57f7c1f0dab4770888c83afd08b89a8dc27aebae408abfd1"
5041
5041
  },
5042
+ {
5043
+ "path": "scripts/gds/publish-credential-check.js",
5044
+ "mode": "0000644",
5045
+ "sha256": "0d700716747617c43e3cf410ef9395cc2f28fff342feef400a8ae45209e893c1"
5046
+ },
5042
5047
  {
5043
5048
  "path": "scripts/gds/publish-manifest.js",
5044
5049
  "mode": "0000644",
@@ -5687,7 +5692,7 @@
5687
5692
  {
5688
5693
  "path": "src/module-api.js",
5689
5694
  "mode": "0000644",
5690
- "sha256": "3e4f5f5d1cb49b46416308dc7a4fa145b8260ca8dd0c63ed1a16ba499b622bc2"
5695
+ "sha256": "95dd72f2698cf288bc35875f4c9a0503393024525c6c2918902e6b22ce1f93f5"
5691
5696
  },
5692
5697
  {
5693
5698
  "path": "src/module-loader/loader.js",
@@ -7232,7 +7237,7 @@
7232
7237
  {
7233
7238
  "path": "tests/publish_branch_route.mjs",
7234
7239
  "mode": "0000644",
7235
- "sha256": "99572892490e209835aae6479370e97b49585bfaa719c788a4d06d92407a9a7d"
7240
+ "sha256": "ea46629917c3236b9dfb7133af643a9fecc9a205323c345e16671b5093606fd6"
7236
7241
  },
7237
7242
  {
7238
7243
  "path": "tests/publish_manifest.mjs",
@@ -7242,7 +7247,7 @@
7242
7247
  {
7243
7248
  "path": "tests/publish_reconciler.mjs",
7244
7249
  "mode": "0000644",
7245
- "sha256": "fe4b594292992429fe5e077491fa2916028a2f1815414f94434f83b4186b1138"
7250
+ "sha256": "ff2428b394a50abf4a593fcbfdb98afe4b56ffdbbffdc81198a102e72b480b9f"
7246
7251
  },
7247
7252
  {
7248
7253
  "path": "tests/rank_gate.mjs",
@@ -38,6 +38,10 @@ A **fine-grained GitHub PAT** scoped to the single repo: `Contents: read/write`
38
38
 
39
39
  > **Update (2026-06-13, task 1028 — GitHub App):** the preferred server credential is now a **GitHub App**, not a standing PAT. The server holds only the App private key (`GITHUB_APP_ID` + `GITHUB_APP_INSTALLATION_ID` + `GITHUB_APP_PRIVATE_KEY`) and mints a short-lived (~1h), repo-scoped **installation token** per push window — so there's no standing push secret at rest. `github-push.js resolveToken()` prefers the App and falls back to the PAT (`GITHUB_PUSH_TOKEN`) during cutover; `isConfigured()` is true if **either** is set, so the route's inert-safe `503` is unchanged. The App JWT is RS256-signed with `node:crypto` (no new dependency). Same single-repo scopes (Contents r/w + PR r/w + Actions read) and the same can't-touch-`main` guarantee. **LIVE on prod as of 2026-06-13 (task 1043 cutover, blocker 31 resolved):** the App (App ID 4047551, installation 140135657, `repository_selection=selected`) is installed and its key is set in `/etc/example/web-source.env`; verified that the server mints + uses an installation token (`publish-status` → 200, and a broken key fails loud as `503`/error rather than falling back). The old `GITHUB_PUSH_TOKEN` was **removed** — there is now **no standing push secret at rest**, only the App private key.
40
40
 
41
+ > **Update (2026-08-12, task 1002733 — verify `Actions: read`, and verify WHICH credential is live):** the 2026-06-13 note above says the PAT was removed and the App is live. **On cloudbongos.com that is no longer true.** Checked on the host this session: <redacted> sets `GITHUB_PUSH_TOKEN` and **none** of `GITHUB_APP_ID` / `GITHUB_APP_INSTALLATION_ID` / `GITHUB_APP_PRIVATE_KEY`. So `appConfigured()` is false and `resolveToken()` falls through to the **PAT** — the App is not the running credential here. Whichever one an instance is actually on, **that** one needs `Actions: read`; don't assume from this ADR's history. If the live credential lacks it, `getPublishStatus`'s deploy probe cannot see workflow runs and reports a merged PR as still deploying **forever**, which stranded task 1002695 at `confirmed` across four reconciler sweeps on 2026-08-12. That failure is invisible from the outside: on a private repo GitHub returns 404 for both "no such workflow" and "not yours", so a single probe cannot tell a legitimately workflow-less instance (the ADR 0108 pin-bump shape) from a blind credential.
42
+ >
43
+ > Check it with `node scripts/gds/publish-credential-check.js` on the host — it lists the repo's workflows as a control call first, so `blind` and `readable_no_workflow` are distinguishable. Task 1002724 makes a blind probe non-fatal (the reconciler falls back to the git-history land proof), but a blind credential means the deploy gate is **not being enforced**, only bypassed.
44
+
41
45
  ## Consequences
42
46
 
43
47
  - A dev-box builder ships end-to-end: `/builder-reauth` → claim → edit → `/builder-stage` ([ADR 0046](<redacted>.md)) → `/builder-ship`, with the GDS doing the push. The box holds no GitHub credential, so the [ADR 0053](0053-scoped-dev-box-session.md) blast-radius argument still holds.
@@ -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,111 @@ 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
+ };
493
+ }
494
+
495
+ // probeDeployVisibility (task 1002733) — can this instance's publish credential READ
496
+ // the Actions API at all?
497
+ //
498
+ // WHY THIS EXISTS. getPublishStatus's deploy probe answers a question about ONE
499
+ // workflow, and on a private repo GitHub deliberately blurs "not there" and "not
500
+ // yours" — both can surface as 404. So a single call cannot distinguish "this repo
501
+ // has no deploy-prod.yml" (fine; the merge is the land, ADR 0108) from "this token
502
+ // cannot see Actions" (a silent misconfiguration that made task 1002695 strand at
503
+ // confirmed across 4 sweeps on 2026-08-12). Until task 1002724 there was no way to
504
+ // tell them apart without a shell on the host and a hand-run of the API.
505
+ //
506
+ // The fix is a CONTROL call: list the repo's workflows. If that succeeds we can read
507
+ // Actions, so a 404 on deploy-prod.yml genuinely means the file is absent. If the
508
+ // list itself is refused, the credential is blind and every deploy verdict it gives
509
+ // is meaningless.
510
+ //
511
+ // ADR 0055 specifies this credential as Contents rw + Pull requests rw + Actions:
512
+ // read. NOTE resolveToken precedence is deps.token > App installation token > PAT, so
513
+ // an instance runs on ONE of them and the other is irrelevant: a correctly-scoped PAT
514
+ // can be masked by a narrower App grant, and equally an App that was never configured
515
+ // means the PAT is live no matter what the ADR's history says. cloudbongos.com is the
516
+ // second case — checked 2026-08-12, web.env sets GITHUB_PUSH_TOKEN and no GITHUB_APP_*.
517
+ // The remediation string below resolves which one is live rather than guessing.
518
+ async function probeDeployVisibility(deps = {}) {
519
+ const token = await resolveToken(deps);
520
+ if (!token) {
521
+ return { state: 'no_credential', http: null, detail: 'no push credential configured (GITHUB_PUSH_TOKEN / App)', remediation: null };
522
+ }
523
+ const repoInfo = deps.repoInfo || loadRepoInfo();
524
+ if (!repoInfo || repoInfo.error) {
525
+ return { state: 'no_repo_info', http: null, detail: 'repo owner/name could not be resolved', remediation: null };
526
+ }
527
+ const { owner, repo } = repoInfo;
528
+ const doFetch = deps.fetchImpl || fetch;
529
+ const api = `https://api.github.com/repos/${owner}/${repo}`;
530
+ // Name the credential that is ACTUALLY live, so the remediation points at the right
531
+ // one. resolveToken() prefers the App and falls back to GITHUB_PUSH_TOKEN, so an
532
+ // instance can be on either — and cloudbongos.com is on the PAT despite ADR 0055's
533
+ // history saying the App replaced it (task 1002733).
534
+ const live = appConfigured() ? 'the GitHub App (GITHUB_APP_* are set — it wins over any PAT)' : 'the PAT in GITHUB_PUSH_TOKEN (no GITHUB_APP_* configured)';
535
+ const GRANT = `Grant 'Actions: read' on ${owner}/${repo} to ${live} (ADR 0055).`;
536
+
537
+ let listRes;
538
+ try {
539
+ listRes = await doFetch(`${api}/actions/workflows?per_page=1`, { headers: ghHeaders(token) });
540
+ } catch (e) {
541
+ return { state: 'unknown', http: null, detail: `workflows list threw: ${e && e.message}`, remediation: null };
542
+ }
543
+ if (!listRes.ok) {
544
+ return {
545
+ state: 'blind',
546
+ http: listRes.status,
547
+ detail: `cannot list workflows (HTTP ${listRes.status}) — every deploy verdict from this credential is unreliable`,
548
+ remediation: GRANT,
549
+ };
550
+ }
551
+
552
+ // Actions is readable. Now the specific workflow: present, or genuinely absent?
553
+ let runsRes;
554
+ try {
555
+ runsRes = await doFetch(`${api}/actions/workflows/deploy-prod.yml/runs?branch=main&per_page=1`, { headers: ghHeaders(token) });
556
+ } catch (e) {
557
+ return { state: 'unknown', http: null, detail: `workflow-runs threw: ${e && e.message}`, remediation: null };
558
+ }
559
+ if (runsRes.ok) {
560
+ return { state: 'readable', http: runsRes.status, detail: 'Actions readable; deploy-prod.yml present — the deploy gate is real', remediation: null };
561
+ }
562
+ if (runsRes.status === 404) {
563
+ return {
564
+ state: 'readable_no_workflow', http: 404,
565
+ detail: 'Actions readable and deploy-prod.yml is genuinely absent — the merge IS the land (ADR 0108 pin-bump publishing)',
566
+ remediation: null,
567
+ };
568
+ }
569
+ return {
570
+ state: 'blind', http: runsRes.status,
571
+ detail: `workflows list succeeded but deploy-prod.yml runs returned HTTP ${runsRes.status}`,
572
+ remediation: GRANT,
573
+ };
451
574
  }
452
575
 
453
576
  // task 1547 — does the PR matched by getPublishStatus actually belong to THIS task?
@@ -755,11 +878,23 @@ async function setCommitStatus({ sha, state, context, description, targetUrl },
755
878
  // is the land, and requiring a deploy that can never run pinned every merged PR at
756
879
  // 'confirmed' forever. MERGED stays mandatory, and this is only the gate on the
757
880
  // branch/PR path — landProvenForTask still has to prove the land is THIS task's.
881
+ // task 1002724 — 'unavailable' deliberately does NOT count as landed here. On an
882
+ // instance that really does deploy, a blind probe must not be promoted into
883
+ // "deployed". It is the RECONCILER that treats blindness differently from a real
884
+ // in-flight deploy (see deployProbeBlind): a probe that cannot see anything must
885
+ // not veto a positive git-history proof of the land.
758
886
  function landConfirmed(status) {
759
887
  if (!status || status.merged !== true) return false;
760
888
  return status.deploy_status === 'success' || status.deploy_status === 'unconfigured';
761
889
  }
762
890
 
891
+ // Is the deploy probe BLIND (we could not read the deploy state) as opposed to
892
+ // legitimately waiting on a deploy we can see? Exported so the reconciler and the
893
+ // publish-status route agree on the distinction.
894
+ function deployProbeBlind(status) {
895
+ return !!status && status.deploy_status === 'unavailable';
896
+ }
897
+
763
898
  // task 1547 — is a landConfirmed status PROVEN to be THIS task's land, and not a
764
899
  // reused worktree branch matching an OLDER task's already-merged PR (the BV1.R04/R05
765
900
  // false-ship: branch claude/jovial-rosalind-84d6c3 → PR #386 / task 1297, merged,
@@ -1090,6 +1225,8 @@ module.exports = {
1090
1225
  recheckPullRequest,
1091
1226
  GATE_OWNER_APPROVED_CONTEXT,
1092
1227
  landConfirmed,
1228
+ deployProbeBlind,
1229
+ probeDeployVisibility,
1093
1230
  landProvenForTask,
1094
1231
  prReferencesTask,
1095
1232
  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.81",
3
+ "version": "1.19.83",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.81",
9
+ "version": "1.19.83",
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.81",
3
+ "version": "1.19.83",
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",
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/publish-credential-check.js — can the publish credential SEE Actions?
3
+ // (task 1002733, promoted from idea 1000705.)
4
+ //
5
+ // Run ON THE HOST, where GITHUB_PUSH_TOKEN / the GitHub App env lives:
6
+ // node scripts/gds/publish-credential-check.js
7
+ //
8
+ // WHY. On 2026-08-12 task 1002695 stranded at 'confirmed' across four reconciler
9
+ // sweeps: its PR was MERGED with green checks, but the server's deploy probe kept
10
+ // answering 'pending' because the credential could not read the Actions API. That
11
+ // blindness was invisible — the only way to find it was to notice a strand and
12
+ // hand-run getPublishStatus against the live server. This turns a silent
13
+ // misconfiguration into a one-command answer.
14
+ //
15
+ // Task 1002724 stopped the blindness from stranding work (a blind probe no longer
16
+ // vetoes a git-history-proven land). This tells you whether you are relying on that
17
+ // fallback instead of a real deploy gate.
18
+ //
19
+ // Exit codes: 0 = credential can read Actions (gate is real, or the workflow is
20
+ // legitimately absent), 1 = BLIND (remediation printed), 2 = nothing to check.
21
+
22
+ const githubPush = require('../../modules/lifecycle/github-push');
23
+ const { cliExit } = require('./cli-lib');
24
+
25
+ const C = process.stdout.isTTY
26
+ ? { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', b: '\x1b[1m', x: '\x1b[0m' }
27
+ : { g: '', y: '', r: '', b: '', x: '' };
28
+
29
+ (async () => {
30
+ if (!githubPush.isConfigured()) {
31
+ console.log(`${C.y}!${C.x} No publish credential configured on this machine.`);
32
+ console.log(' This check belongs on the server/host that holds GITHUB_PUSH_TOKEN or the GitHub App env.');
33
+ return cliExit(2);
34
+ }
35
+
36
+ let probe;
37
+ try {
38
+ probe = await githubPush.probeDeployVisibility();
39
+ } catch (e) {
40
+ console.log(`${C.r}✖${C.x} probe failed: ${e && e.message}`);
41
+ return cliExit(1);
42
+ }
43
+
44
+ const http = probe.http ? ` (HTTP ${probe.http})` : '';
45
+ switch (probe.state) {
46
+ case 'readable':
47
+ console.log(`${C.g}✓${C.x} ${C.b}Actions readable${C.x}${http} — ${probe.detail}`);
48
+ return cliExit(0);
49
+ case 'readable_no_workflow':
50
+ console.log(`${C.g}✓${C.x} ${C.b}Actions readable${C.x}${http} — ${probe.detail}`);
51
+ return cliExit(0);
52
+ case 'blind':
53
+ console.log(`${C.r}✖${C.x} ${C.b}BLIND${C.x}${http} — ${probe.detail}`);
54
+ console.log('');
55
+ console.log(` ${C.b}Fix:${C.x} ${probe.remediation}`);
56
+ console.log(' Until then the reconciler falls back to the git-history land proof (task 1002724),');
57
+ console.log(' so work still lands — but the deploy gate is not actually being enforced.');
58
+ return cliExit(1);
59
+ default:
60
+ console.log(`${C.y}!${C.x} ${probe.state}${http} — ${probe.detail}`);
61
+ return cliExit(2);
62
+ }
63
+ })();
package/src/module-api.js CHANGED
@@ -589,8 +589,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
589
589
  // landed since 1.19.79 with no explicit bump. run 31649366675. (task 1002620)
590
590
  // 1.19.81 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
591
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)
594
+ // 1.19.83 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
595
+ // landed since 1.19.82 with no explicit bump. run 31651046759. (task 1002620)
592
596
  // ---------------------------------------------------------------------------
593
- const CORE_VERSION = '1.19.81'; // CI auto-patch carrier (ADR 0161); see changelog above
597
+ const CORE_VERSION = '1.19.83'; // CI auto-patch carrier (ADR 0161); see changelog above
594
598
 
595
599
  // A namespaced logger so a module's log lines are attributable + consistent.
596
600
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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) => {
@@ -509,5 +570,74 @@ await t('landConfirmed: unconfigured deploy counts as landed, but ONLY when merg
509
570
  assert.equal(gp.landConfirmed({ merged: true, deploy_status: 'none' }), false, 'none ≠ unconfigured — no PR at all is not a land');
510
571
  });
511
572
 
573
+ // ===========================================================================
574
+ // task 1002733 — probeDeployVisibility: is the publish credential BLIND?
575
+ //
576
+ // On a private repo GitHub deliberately blurs "not there" and "not yours" — both
577
+ // can surface as 404 — so one call cannot tell "this repo has no deploy-prod.yml"
578
+ // (fine) from "this credential cannot read Actions" (the silent misconfiguration
579
+ // that stranded task 1002695 across 4 sweeps). The CONTROL call is listing the
580
+ // repo's workflows: if that works, a 404 on the specific workflow is genuine.
581
+ // ===========================================================================
582
+
583
+ await t('probeDeployVisibility: workflows list 403 → blind, with remediation', async () => {
584
+ const fetchImpl = async (url) => {
585
+ if (url.includes('/actions/workflows?')) return jsonRes(403, { message: 'Resource not accessible' });
586
+ throw new Error('must not probe the specific workflow once the list is refused: ' + url);
587
+ };
588
+ const p = await gp.probeDeployVisibility({ token: 'TT', repoInfo: REPO, fetchImpl });
589
+ assert.equal(p.state, 'blind');
590
+ assert.equal(p.http, 403);
591
+ assert.match(p.remediation, /Actions: read/i);
592
+ // The remediation must name the credential that is ACTUALLY live rather than
593
+ // guessing: resolveToken prefers the App and falls back to GITHUB_PUSH_TOKEN, and an
594
+ // instance runs on exactly one of them. Pointing at the wrong one sends the operator
595
+ // to widen permissions on a credential that is not in play (cloudbongos.com is on
596
+ // the PAT, despite ADR 0055's history saying the App replaced it — task 1002733).
597
+ assert.match(
598
+ p.remediation, /GITHUB_APP_\*|GITHUB_PUSH_TOKEN/,
599
+ 'must identify which credential resolveToken will actually use'
600
+ );
601
+ });
602
+
603
+ await t('probeDeployVisibility: list OK + runs 404 → readable_no_workflow (NOT blind)', async () => {
604
+ const fetchImpl = async (url) => {
605
+ if (url.includes('/actions/workflows?')) return jsonRes(200, { workflows: [{ id: 1, path: '.github/workflows/unit.yml' }] });
606
+ if (url.includes('deploy-prod.yml/runs')) return jsonRes(404, { message: 'Not Found' });
607
+ throw new Error('unexpected ' + url);
608
+ };
609
+ const p = await gp.probeDeployVisibility({ token: 'TT', repoInfo: REPO, fetchImpl });
610
+ assert.equal(p.state, 'readable_no_workflow', 'a 404 behind a readable list means the workflow is genuinely absent');
611
+ assert.equal(p.remediation, null, 'nothing to fix — this is the ADR 0108 pin-bump shape');
612
+ });
613
+
614
+ await t('probeDeployVisibility: list OK + runs OK → readable (the deploy gate is real)', async () => {
615
+ const fetchImpl = async (url) => {
616
+ if (url.includes('/actions/workflows?')) return jsonRes(200, { workflows: [] });
617
+ if (url.includes('deploy-prod.yml/runs')) return jsonRes(200, { workflow_runs: [] });
618
+ throw new Error('unexpected ' + url);
619
+ };
620
+ const p = await gp.probeDeployVisibility({ token: 'TT', repoInfo: REPO, fetchImpl });
621
+ assert.equal(p.state, 'readable');
622
+ assert.equal(p.remediation, null);
623
+ });
624
+
625
+ await t('probeDeployVisibility: list OK but runs 403 → still blind', async () => {
626
+ const fetchImpl = async (url) => {
627
+ if (url.includes('/actions/workflows?')) return jsonRes(200, { workflows: [] });
628
+ if (url.includes('deploy-prod.yml/runs')) return jsonRes(403, { message: 'nope' });
629
+ throw new Error('unexpected ' + url);
630
+ };
631
+ const p = await gp.probeDeployVisibility({ token: 'TT', repoInfo: REPO, fetchImpl });
632
+ assert.equal(p.state, 'blind');
633
+ assert.equal(p.http, 403);
634
+ });
635
+
636
+ await t('probeDeployVisibility: no credential → no_credential, not a false alarm', async () => {
637
+ const p = await gp.probeDeployVisibility({ token: null, repoInfo: REPO, fetchImpl: async () => { throw new Error('must not fetch'); } });
638
+ assert.equal(p.state, 'no_credential');
639
+ assert.equal(p.remediation, null);
640
+ });
641
+
512
642
  console.log(`\n${pass} passed, ${fail} failed`);
513
643
  process.exit(fail === 0 ? 0 : 1);
@@ -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 }],