@bongos/core 1.19.81 → 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 +13 -13
- package/docs/recipes/ops-gotchas.md +11 -0
- package/modules/lifecycle/github-push.js +66 -11
- package/modules/lifecycle/publish-reconciler.js +53 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/publish_branch_route.mjs +63 -2
- package/tests/publish_reconciler.mjs +119 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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:
|
|
9
|
+
"built_at": "2026-08-12T23:14:31.232Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 295,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1543,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "253f8de922c6d8cc2023e6a2d28b363fafa2cfdec666f96afc3a0e5d879a918f",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1572,7 +1572,7 @@
|
|
|
1572
1572
|
{
|
|
1573
1573
|
"path": "docs/recipes/ops-gotchas.md",
|
|
1574
1574
|
"mode": "0000644",
|
|
1575
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4060
|
+
"sha256": "b41abda8346dddd1c07e48067f2d9d0127aaad506519710b08cea5373a993b98"
|
|
4061
4061
|
},
|
|
4062
4062
|
{
|
|
4063
4063
|
"path": "package.json",
|
|
4064
4064
|
"mode": "0000644",
|
|
4065
|
-
"sha256": "
|
|
4065
|
+
"sha256": "0ed0a2fa31ababaeef64de35d41cba12b4308fef35fe2d831a0532ac503581c8"
|
|
4066
4066
|
},
|
|
4067
4067
|
{
|
|
4068
4068
|
"path": "public-docs/index.html",
|
|
@@ -5687,7 +5687,7 @@
|
|
|
5687
5687
|
{
|
|
5688
5688
|
"path": "src/module-api.js",
|
|
5689
5689
|
"mode": "0000644",
|
|
5690
|
-
"sha256": "
|
|
5690
|
+
"sha256": "793ab7f173efc8c4137e658cae4617f09a9c0df1e1783cd28396337eac6aa83b"
|
|
5691
5691
|
},
|
|
5692
5692
|
{
|
|
5693
5693
|
"path": "src/module-loader/loader.js",
|
|
@@ -7232,7 +7232,7 @@
|
|
|
7232
7232
|
{
|
|
7233
7233
|
"path": "tests/publish_branch_route.mjs",
|
|
7234
7234
|
"mode": "0000644",
|
|
7235
|
-
"sha256": "
|
|
7235
|
+
"sha256": "e3f843387deb2d2a8f2668490bc740f7291d141b2de70c1556a84cb429bdc21d"
|
|
7236
7236
|
},
|
|
7237
7237
|
{
|
|
7238
7238
|
"path": "tests/publish_manifest.mjs",
|
|
@@ -7242,7 +7242,7 @@
|
|
|
7242
7242
|
{
|
|
7243
7243
|
"path": "tests/publish_reconciler.mjs",
|
|
7244
7244
|
"mode": "0000644",
|
|
7245
|
-
"sha256": "
|
|
7245
|
+
"sha256": "ff2428b394a50abf4a593fcbfdb98afe4b56ffdbbffdc81198a102e72b480b9f"
|
|
7246
7246
|
},
|
|
7247
7247
|
{
|
|
7248
7248
|
"path": "tests/rank_gate.mjs",
|
|
@@ -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,
|
|
387
|
-
//
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -589,8 +589,10 @@ 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)
|
|
592
594
|
// ---------------------------------------------------------------------------
|
|
593
|
-
const CORE_VERSION = '1.19.
|
|
595
|
+
const CORE_VERSION = '1.19.82'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
594
596
|
|
|
595
597
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
596
598
|
// 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
|
-
|
|
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, '
|
|
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 }],
|