@bongos/core 1.19.38 → 1.19.39

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,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.38",
6
- "core_contract": "1.19.38",
7
- "source_commit": "ba6a8f6285476ab3cd4e33f9278b333ac0fc3a29",
5
+ "core_version": "1.19.39",
6
+ "core_contract": "1.19.39",
7
+ "source_commit": "d61c48b44f6f117383794f20d49b5bd7b83d35d7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T16:17:20.571Z",
9
+ "built_at": "2026-08-11T16:25:51.076Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 288,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1521,
20
- "tree_sha256": "9d55773209aeb85fef771e85aa30e901c52e54e155601030e44cd16fd5c46bda",
20
+ "tree_sha256": "14c889242599811c768effd9bc7ef844e4a6ee4ef0a041c74ff1629fda6f8907",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -3382,7 +3382,7 @@
3382
3382
  {
3383
3383
  "path": "modules/lifecycle/github-push.js",
3384
3384
  "mode": "0000644",
3385
- "sha256": "60b9338ed47d01e9f38fe3fa9f2f2cde6ff241b8e47efcb8fcf4c4f19d32c766"
3385
+ "sha256": "9658ccf0dd332d6c4ce1e269c2966ebe2e2bed5f909d5d503722d8e8cfb1ebbc"
3386
3386
  },
3387
3387
  {
3388
3388
  "path": "modules/lifecycle/goal-advisory.js",
@@ -3437,7 +3437,7 @@
3437
3437
  {
3438
3438
  "path": "modules/lifecycle/publish-reconciler.js",
3439
3439
  "mode": "0000644",
3440
- "sha256": "964af2331007a4cfb9142e7245d7625cf21c973e4f885e0171855b9e188b9e95"
3440
+ "sha256": "ad57ad242956c6ee3dc4aeddd66e980af48737fab644765aa3c400720f4a9668"
3441
3441
  },
3442
3442
  {
3443
3443
  "path": "modules/lifecycle/routes/analytics.js",
@@ -4022,12 +4022,12 @@
4022
4022
  {
4023
4023
  "path": "package-lock.json",
4024
4024
  "mode": "0000644",
4025
- "sha256": "413f575fce07ba5b167f6492a4f1aed1f7eacb4f4351457303d3875f04557787"
4025
+ "sha256": "12291ed6b1927973c8d8c42c5384d7ba2368779b9a39c566336253069214cfb2"
4026
4026
  },
4027
4027
  {
4028
4028
  "path": "package.json",
4029
4029
  "mode": "0000644",
4030
- "sha256": "d0d31ac616a949591bca2975236bbf6d98437e969d1618bd68811620c2a130c3"
4030
+ "sha256": "18139931183bcb89c64704ae5167ab99d1e8ae9841728870b277959af8523069"
4031
4031
  },
4032
4032
  {
4033
4033
  "path": "public-docs/index.html",
@@ -5647,7 +5647,7 @@
5647
5647
  {
5648
5648
  "path": "src/module-api.js",
5649
5649
  "mode": "0000644",
5650
- "sha256": "2dfd2867ab83689497aff7a3b7f213e756c182e047ef8da754e098a945491ce7"
5650
+ "sha256": "5f51d5f403c88eedf838dab85e4a9ddfa8ff575ec07b5f1189e12999001a335a"
5651
5651
  },
5652
5652
  {
5653
5653
  "path": "src/module-loader/loader.js",
@@ -6417,7 +6417,7 @@
6417
6417
  {
6418
6418
  "path": "tests/github_push_merge_driver.mjs",
6419
6419
  "mode": "0000644",
6420
- "sha256": "1e038780532078d8d1a20feebb95ee6762fb300101f71cfff7076d2fa0d38b1b"
6420
+ "sha256": "850113ed91babc9eb54dc22231f194d283fd3f94cee9deef1e6e0bf6f3a969c2"
6421
6421
  },
6422
6422
  {
6423
6423
  "path": "tests/github_repos_helpers.mjs",
@@ -7147,7 +7147,7 @@
7147
7147
  {
7148
7148
  "path": "tests/publish_reconciler.mjs",
7149
7149
  "mode": "0000644",
7150
- "sha256": "240337de03354de198d6baaaca9f33b267aceb40a8ba43d31303eb21fad9226c"
7150
+ "sha256": "1aeb87a14e411160784c94ee5392f323a2ad0a373bd9acf91337289041e8d2bc"
7151
7151
  },
7152
7152
  {
7153
7153
  "path": "tests/rank_gate.mjs",
@@ -675,6 +675,40 @@ async function mergeIfGreen({ branch }, deps = {}) {
675
675
  return { ...base, reason: `merge_api_${res.status}`, detail: detail.slice(0, 160) };
676
676
  }
677
677
 
678
+ // task 1002666 — the carrier race. Publish-on-merge (ADR 0161) pushes a carrier
679
+ // commit to main after EVERY land, so under an up-to-date branch-protection rule
680
+ // a PR whose checks report after the previous merge's carrier goes BEHIND:
681
+ // green, mergeable, and permanently stalled (GitHub's update-branch also disarms
682
+ // auto-merge, so re-arming alone cannot recover it — observed on PR #48,
683
+ // 2026-08-11). Server-side remedy: update the branch; checks re-run on the fresh
684
+ // head and the next sweep's mergeIfGreen lands it. Fires ONLY on the definitive
685
+ // BEHIND shape — GitHub reports the strict-up-to-date green-PR stall as BEHIND
686
+ // once checks have reported; BLOCKED means checks are pending/missing and stays
687
+ // untouched (updating there would loop on PRs that can never go green).
688
+ // The state is re-read here even though the sweep's mergeIfGreen just read it:
689
+ // update-branch is a WRITE, and the re-read guards against acting on a state
690
+ // that changed between the two calls (e.g. the PR merged meanwhile) — the same
691
+ // read-before-write discipline mergeIfGreen itself applies.
692
+ async function updateBranchIfBehind({ branch }, deps = {}) {
693
+ const s = await getMergeState({ branch }, deps);
694
+ const base = { number: s.number, updated_now: false, merge_state: s.merge_state, reason: null };
695
+ if (s.merged) return { ...base, reason: 'already_merged' };
696
+ if (s.pr_state !== 'open') return { ...base, reason: 'no_open_pr' };
697
+ if (s.merge_state !== 'BEHIND') return { ...base, reason: mergeBlockReason(s) };
698
+ const token = await resolveToken(deps);
699
+ const repoInfo = deps.repoInfo || loadRepoInfo();
700
+ const { owner, repo } = repoInfo;
701
+ const doFetch = deps.fetchImpl || fetch;
702
+ const res = await doFetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${s.number}/update-branch`, {
703
+ method: 'PUT',
704
+ headers: { ...ghHeaders(token), 'Content-Type': 'application/json' },
705
+ body: JSON.stringify(s.head_sha ? { expected_head_sha: s.head_sha } : {}),
706
+ });
707
+ if (res.ok) return { ...base, updated_now: true };
708
+ const detail = await res.text().catch(() => '');
709
+ return { ...base, reason: `update_api_${res.status}`, detail: detail.slice(0, 160) };
710
+ }
711
+
678
712
  // task 1139 (ADR 0058): server-set commit status — the TRUSTLESS rank attestation the
679
713
  // gate-review CI check reads. Only the server (holding the App/PAT credential AND the
680
714
  // authenticated builder's server-resolved rank) can set it, so CI can trust it where it
@@ -1043,6 +1077,7 @@ module.exports = {
1043
1077
  getPublishStatus,
1044
1078
  getMergeState,
1045
1079
  mergeIfGreen,
1080
+ updateBranchIfBehind,
1046
1081
  setCommitStatus,
1047
1082
  setGateOwnerApproved,
1048
1083
  listOpenPullRequests,
@@ -200,6 +200,7 @@ async function runSweep(deps = {}) {
200
200
  let merged = 0;
201
201
  let resolvedConflicts = 0;
202
202
  let refreshedStale = 0; // task 1915: PRs whose stale generated indexes we regen+pushed
203
+ let behindUpdated = 0; // task 1002666: green PRs behind main (carrier race) whose branch we updated
203
204
  let errors = 0;
204
205
  let unprovable = 0; // confirmed tasks the sweep could not prove landed this pass
205
206
  let staleStrands = 0; // of those, the ones older than STALE_CONFIRMED_MS (WARN'd)
@@ -305,6 +306,38 @@ async function runSweep(deps = {}) {
305
306
  } catch (e) {
306
307
  errFn(`[publish-reconciler] conflict-resolve for task ${r.task_id} (non-blocking): ${e && e.message}`);
307
308
  }
309
+ } else if (
310
+ m && !m.merged && m.reason === 'branch_behind_base' &&
311
+ typeof push.updateBranchIfBehind === 'function'
312
+ ) {
313
+ // task 1002666: the ADR 0161 carrier race — every land pushes a
314
+ // carrier commit to main, so a green PR whose checks reported after
315
+ // the previous merge sits BEHIND forever (GitHub's update-branch
316
+ // disarms auto-merge, so it cannot recover on its own). Update the
317
+ // branch server-side; checks re-run on the fresh head and the NEXT
318
+ // sweep's mergeIfGreen lands it. Attempt-capped per branch on the
319
+ // shared heal map — on a hot fleet every update re-opens the race
320
+ // window, and a treadmill must surface, not spin.
321
+ const bKey = `behind:${branch}`;
322
+ const bn = attempts.get(bKey) || 0;
323
+ if (bn >= MAX_STALE_HEAL_ATTEMPTS) {
324
+ logFn(
325
+ `[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} still behind main after ${bn} update-branch attempt(s) — capped (merge traffic is outracing this PR); left for a human`
326
+ );
327
+ } else {
328
+ attempts.set(bKey, bn + 1);
329
+ try {
330
+ const u = await push.updateBranchIfBehind({ branch });
331
+ if (u && u.updated_now) {
332
+ behindUpdated++;
333
+ logFn(
334
+ `[publish-reconciler] task ${r.task_id} (${branch}) PR #${u.number} was green but behind main (carrier race) — server-updated the branch; checks re-run, lands next sweep`
335
+ );
336
+ }
337
+ } catch (e) {
338
+ errFn(`[publish-reconciler] update-branch for task ${r.task_id} (non-blocking): ${e && e.message}`);
339
+ }
340
+ }
308
341
  } else if (
309
342
  m && couldBeStaleGenerated(m) &&
310
343
  typeof resolver.refreshStaleGenerated === 'function'
@@ -544,14 +577,17 @@ async function runSweep(deps = {}) {
544
577
  }
545
578
 
546
579
  // Prune the treadmill guard so it can't leak: a branch that left the confirmed set
547
- // (landed, or released) never returns under the same name, so drop it.
580
+ // (landed, or released) never returns under the same name, so drop it. Keys may be
581
+ // namespaced ('behind:<branch>', task 1002666) — compare on the branch itself so a
582
+ // live branch's cap survives across sweeps and a departed branch's keys still prune.
548
583
  for (const b of attempts.keys()) {
549
- if (!branchesSeen.has(b)) attempts.delete(b);
584
+ const plain = b.startsWith('behind:') ? b.slice('behind:'.length) : b;
585
+ if (!branchesSeen.has(plain)) attempts.delete(b);
550
586
  }
551
587
 
552
- if (reconciled || merged || resolvedConflicts || refreshedStale) {
588
+ if (reconciled || merged || resolvedConflicts || refreshedStale || behindUpdated) {
553
589
  logFn(
554
- `[publish-reconciler] swept ${rows.length} confirmed task(s); merged ${merged} stalled PR(s); auto-resolved ${resolvedConflicts} generated/index conflict(s); regen-healed ${refreshedStale} stale-generated PR(s); reconciled ${reconciled} → shipped`
590
+ `[publish-reconciler] swept ${rows.length} confirmed task(s); merged ${merged} stalled PR(s); auto-resolved ${resolvedConflicts} generated/index conflict(s); regen-healed ${refreshedStale} stale-generated PR(s); branch-updated ${behindUpdated} behind-main PR(s); reconciled ${reconciled} → shipped`
555
591
  );
556
592
  }
557
593
  if (staleStrands) {
@@ -559,7 +595,7 @@ async function runSweep(deps = {}) {
559
595
  `[publish-reconciler] ${staleStrands} stale strand(s): confirmed >${Math.round(STALE_CONFIRMED_MS / 60_000)}min with no provable land — credited but unlanded; see STRAND lines above`
560
596
  );
561
597
  }
562
- return { checked: rows.length, reconciled, merged, resolvedConflicts, refreshedStale, errors, unprovable, staleStrands, strandsFlagged };
598
+ return { checked: rows.length, reconciled, merged, resolvedConflicts, refreshedStale, behindUpdated, errors, unprovable, staleStrands, strandsFlagged };
563
599
  }
564
600
 
565
601
  // task 1366 — server-side session-cost reconcile, piggybacked on the same 5-min
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.38",
3
+ "version": "1.19.39",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.38",
9
+ "version": "1.19.39",
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.38",
3
+ "version": "1.19.39",
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
@@ -503,8 +503,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
503
503
  // landed since 1.19.36 with no explicit bump. run 31510590459. (task 1002620)
504
504
  // 1.19.38 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
505
505
  // landed since 1.19.37 with no explicit bump. run 31511537558. (task 1002620)
506
+ // 1.19.39 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
507
+ // landed since 1.19.38 with no explicit bump. run 31512284325. (task 1002620)
506
508
  // ---------------------------------------------------------------------------
507
- const CORE_VERSION = '1.19.38'; // CI auto-patch carrier (ADR 0161); see changelog above
509
+ const CORE_VERSION = '1.19.39'; // CI auto-patch carrier (ADR 0161); see changelog above
508
510
 
509
511
  // A namespaced logger so a module's log lines are attributable + consistent.
510
512
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -169,6 +169,64 @@ test('mergeBlockReason maps the common blocker states', () => {
169
169
  assert.equal(gh.mergeBlockReason({ ...open, merged: true }), null);
170
170
  });
171
171
 
172
+ // --- task 1002666: updateBranchIfBehind (the carrier-race branch updater) ---
173
+
174
+ // fetchImpl serving graphql + recording the update-branch PUT.
175
+ function mkUpdateFetch({ node, updateStatus = 202 } = {}) {
176
+ const calls = { graphql: 0, update: 0, updateArgs: null };
177
+ const fetchImpl = async (url, opts = {}) => {
178
+ if (String(url).includes('/graphql')) {
179
+ calls.graphql++;
180
+ return {
181
+ ok: true, status: 200,
182
+ json: async () => ({ data: { repository: { pullRequests: { nodes: node ? [node] : [] } } } }),
183
+ };
184
+ }
185
+ if (String(url).includes('/update-branch')) {
186
+ calls.update++;
187
+ calls.updateArgs = { url, body: JSON.parse(opts.body || '{}') };
188
+ return {
189
+ ok: updateStatus >= 200 && updateStatus < 300,
190
+ status: updateStatus,
191
+ text: async () => '{}',
192
+ };
193
+ }
194
+ throw new Error(`unexpected fetch: ${url}`);
195
+ };
196
+ return { fetchImpl, calls };
197
+ }
198
+
199
+ test('updateBranchIfBehind updates a BEHIND PR, pinning the expected head sha', async () => {
200
+ const { fetchImpl, calls } = mkUpdateFetch({ node: prNode({ mergeStateStatus: 'BEHIND', oid: 'headsha' }) });
201
+ const u = await gh.updateBranchIfBehind({ branch: 'claude/wt' }, DEPS(fetchImpl));
202
+ assert.equal(u.updated_now, true);
203
+ assert.equal(calls.update, 1);
204
+ assert.match(String(calls.updateArgs.url), /\/pulls\/125\/update-branch$/);
205
+ assert.equal(calls.updateArgs.body.expected_head_sha, 'headsha', 'a raced head 422s instead of updating blind');
206
+ });
207
+
208
+ test('updateBranchIfBehind refuses every non-BEHIND shape without calling the API', async () => {
209
+ for (const [node, reason] of [
210
+ [prNode({ mergeStateStatus: 'CLEAN' }), 'clean'],
211
+ [prNode({ merged: true }), 'already_merged'],
212
+ [prNode({ state: 'CLOSED' }), 'no_open_pr'],
213
+ [prNode({ mergeStateStatus: 'BLOCKED' }), 'blocked_required_review_or_pending_check'],
214
+ ]) {
215
+ const { fetchImpl, calls } = mkUpdateFetch({ node });
216
+ const u = await gh.updateBranchIfBehind({ branch: 'claude/wt' }, DEPS(fetchImpl));
217
+ assert.equal(u.updated_now, false);
218
+ assert.equal(calls.update, 0, 'no update PUT for a non-BEHIND PR');
219
+ if (reason) assert.equal(u.reason, reason);
220
+ }
221
+ });
222
+
223
+ test('updateBranchIfBehind surfaces an API refusal as update_api_<status>', async () => {
224
+ const { fetchImpl } = mkUpdateFetch({ node: prNode({ mergeStateStatus: 'BEHIND' }), updateStatus: 403 });
225
+ const u = await gh.updateBranchIfBehind({ branch: 'claude/wt' }, DEPS(fetchImpl));
226
+ assert.equal(u.updated_now, false);
227
+ assert.equal(u.reason, 'update_api_403');
228
+ });
229
+
172
230
  setTimeout(() => {
173
231
  console.log(`\ngithub-push merge driver (task 1162) tests: ${passed} passed, ${failed} failed`);
174
232
  process.exit(failed > 0 ? 1 : 0);
@@ -57,9 +57,10 @@ function mkDeps({
57
57
  broadcastImpl,
58
58
  conflictResolveImpl,
59
59
  refreshStaleImpl,
60
+ updateByBranch = {},
60
61
  healAttempts = new Map(),
61
62
  } = {}) {
62
- const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], search: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
63
+ const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], search: [], resolve: [], refresh: [], update: [], flag: [], events: [], logs: [], errors: [] };
63
64
  // isSafeBranch mirrors github-push's pattern enough for the test (rejects
64
65
  // option-like / traversal refs); we use the REAL module so the contract can't drift.
65
66
  const githubPush = require('../modules/lifecycle/github-push.js');
@@ -110,6 +111,15 @@ function mkDeps({
110
111
  if (f instanceof Error) throw f;
111
112
  return f || null;
112
113
  },
114
+ // task 1002666: the carrier-race branch updater. Default: no open PR (a
115
+ // no-op), so pre-1002666 tests keep their exact behavior. Script per
116
+ // branch via updateByBranch; an Error value throws (swallowed, non-blocking).
117
+ updateBranchIfBehind: async ({ branch }) => {
118
+ calls.update.push(branch);
119
+ const u = updateByBranch[branch];
120
+ if (u instanceof Error) throw u;
121
+ return u || { updated_now: false, reason: 'no_open_pr' };
122
+ },
113
123
  // task 1313 head_sha land-check. Default: not on main. An Error value throws
114
124
  // (to prove a transient compare-API miss is swallowed, leaving it for next sweep).
115
125
  isAncestorOfDefaultBranch: async ({ sha }) => {
@@ -790,6 +800,79 @@ test('1915 mergeable-but-red PR → regen-heal fires on the right branch; refres
790
800
  assert.equal(deps._calls.ship.length, 0, 'the heal never ships (no phantom-ship — land still needs the tip proof)');
791
801
  });
792
802
 
803
+ // --- task 1002666: the carrier race — green PR behind main gets its branch updated ---
804
+
805
+ // mergeIfGreen shape for a green, mergeable PR that fell behind main (the ADR 0161
806
+ // carrier landed after this PR's checks reported).
807
+ const behindGreen = (number = 48) => ({
808
+ pr_state: 'open', number, merged: false, merged_now: false, mergeable: 'MERGEABLE', reason: 'branch_behind_base',
809
+ });
810
+
811
+ test('1002666 green-but-behind PR (carrier race) → server updates the branch; NOT shipped this sweep', async () => {
812
+ const deps = mkDeps({
813
+ rows: [{ task_id: 500, builder_id: 5, worktree_name: 'wt-behind' }],
814
+ statusByBranch: { 'claude/wt-behind': PENDING },
815
+ mergeByBranch: { 'claude/wt-behind': behindGreen() },
816
+ updateByBranch: { 'claude/wt-behind': { number: 48, updated_now: true, merge_state: 'BEHIND', reason: null } },
817
+ });
818
+ const r = await reconciler.runSweep(deps);
819
+ assert.equal(r.behindUpdated, 1, 'the behind PR was branch-updated');
820
+ assert.deepEqual(deps._calls.update, ['claude/wt-behind'], 'the update is invoked on exactly this task\'s branch');
821
+ assert.equal(r.reconciled, 0, 'no ship this sweep — checks re-run on the new head, lands next sweep');
822
+ assert.equal(deps._calls.ship.length, 0, 'the update never ships (land still needs the tip proof)');
823
+ assert.ok(deps._calls.logs.some((l) => /carrier race/.test(l)), 'the log names the carrier race');
824
+ });
825
+
826
+ test('1002666 update-branch attempts are capped on the shared heal map (a hot fleet must surface, not spin)', async () => {
827
+ const healAttempts = new Map([['behind:claude/wt-behind', 3]]);
828
+ const deps = mkDeps({
829
+ rows: [{ task_id: 501, builder_id: 5, worktree_name: 'wt-behind' }],
830
+ statusByBranch: { 'claude/wt-behind': PENDING },
831
+ mergeByBranch: { 'claude/wt-behind': behindGreen() },
832
+ updateByBranch: { 'claude/wt-behind': { number: 48, updated_now: true } },
833
+ healAttempts,
834
+ });
835
+ const r = await reconciler.runSweep(deps);
836
+ assert.equal(deps._calls.update.length, 0, 'at the cap the updater is not invoked');
837
+ assert.equal(r.behindUpdated, 0);
838
+ assert.ok(deps._calls.logs.some((l) => /capped/.test(l)), 'the cap is logged, not silent');
839
+ });
840
+
841
+ test('1002666 the behind-cap ACCUMULATES across sweeps for a live branch (the prune must not reset it)', async () => {
842
+ const healAttempts = new Map();
843
+ const mk = () => mkDeps({
844
+ rows: [{ task_id: 503, builder_id: 5, worktree_name: 'wt-behind' }],
845
+ statusByBranch: { 'claude/wt-behind': PENDING },
846
+ mergeByBranch: { 'claude/wt-behind': behindGreen() },
847
+ updateByBranch: { 'claude/wt-behind': { number: 48, updated_now: true } },
848
+ healAttempts,
849
+ });
850
+ for (let i = 1; i <= 3; i++) {
851
+ await reconciler.runSweep(mk());
852
+ assert.equal(healAttempts.get('behind:claude/wt-behind'), i, `attempt ${i} is remembered after the sweep's prune`);
853
+ }
854
+ const fourth = mk();
855
+ const r = await reconciler.runSweep(fourth);
856
+ assert.equal(fourth._calls.update.length, 0, 'the 4th sweep is capped — a permanently-behind PR surfaces instead of spinning');
857
+ assert.equal(r.behindUpdated, 0);
858
+ // A branch that LEAVES the confirmed set prunes its namespaced key too (no leak).
859
+ const empty = mkDeps({ rows: [], healAttempts });
860
+ await reconciler.runSweep(empty);
861
+ assert.equal(healAttempts.has('behind:claude/wt-behind'), false, 'departed branch prunes its behind: key');
862
+ });
863
+
864
+ test('1002666 an update-branch API failure is swallowed (non-blocking) and the sweep continues', async () => {
865
+ const deps = mkDeps({
866
+ rows: [{ task_id: 502, builder_id: 5, worktree_name: 'wt-behind' }],
867
+ statusByBranch: { 'claude/wt-behind': PENDING },
868
+ mergeByBranch: { 'claude/wt-behind': behindGreen() },
869
+ updateByBranch: { 'claude/wt-behind': new Error('update-branch 403') },
870
+ });
871
+ const r = await reconciler.runSweep(deps);
872
+ assert.equal(r.behindUpdated, 0);
873
+ assert.ok(deps._calls.errors.some((l) => /update-branch/.test(l)), 'the failure is logged to errFn');
874
+ });
875
+
793
876
  test('1915 mergeable-but-red PR where regen yields NO diff → not healed, logged as a real check failure', async () => {
794
877
  const deps = mkDeps({
795
878
  rows: [{ task_id: 401, builder_id: 5, worktree_name: 'wt-realfail' }],