@bongos/core 1.19.62 → 1.19.64

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.62",
6
- "core_contract": "1.19.62",
7
- "source_commit": "81537ad6976770dfa5d0e9ff4aae44c6cc00be9f",
5
+ "core_version": "1.19.64",
6
+ "core_contract": "1.19.64",
7
+ "source_commit": "ce3ba6421d4c20ca1f0b218a1b09d8825db932b1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-12T01:12:59.066Z",
9
+ "built_at": "2026-08-12T03:26:56.981Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 291,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1532,
20
- "tree_sha256": "d7adb6b51e99575f2dbed8932206114b763bc19d3d4b880f41b091710892920b",
20
+ "tree_sha256": "c8e496c8bd2e61bc44e0ccb85fcceadb92acbeaa5ac0c7db1f856004a36f2728",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -947,7 +947,7 @@
947
947
  {
948
948
  "path": "docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md",
949
949
  "mode": "0000644",
950
- "sha256": "9613664f0f3906aeda7c384c3a6dfaacb8a0a048b0010da8a011831846d7911e"
950
+ "sha256": "3aa1e9074bd62168f999139533d672b0e0369186e2a78bbc966bca940df8d012"
951
951
  },
952
952
  {
953
953
  "path": "docs/adr/0097-retroactive-reward-backfill.md",
@@ -1377,7 +1377,7 @@
1377
1377
  {
1378
1378
  "path": "docs/file-map.md",
1379
1379
  "mode": "0000644",
1380
- "sha256": "01f8ff6aaa583d333b466f4d5cff849c09327653c33dcb3d82d60cc46f913cb8"
1380
+ "sha256": "4b3ee1ce8445659a2660263cbbd9e2a903d010410b8ff8f89e2ed61ed54c6eab"
1381
1381
  },
1382
1382
  {
1383
1383
  "path": "docs/handoff-template.md",
@@ -2557,7 +2557,7 @@
2557
2557
  {
2558
2558
  "path": "modules/dev-box/app/test/unit.mjs",
2559
2559
  "mode": "0000644",
2560
- "sha256": "6c209f446990af95d16995720a4746d4890ed77c77ebc13f22293404ea0d3eea"
2560
+ "sha256": "104360917db40f8c6de633ed692b50417e5cf55f316d97fc4d29285046ff2324"
2561
2561
  },
2562
2562
  {
2563
2563
  "path": "modules/dev-box/box-access.js",
@@ -3382,7 +3382,7 @@
3382
3382
  {
3383
3383
  "path": "modules/lifecycle/db.js",
3384
3384
  "mode": "0000644",
3385
- "sha256": "37303ffa7a795ea41db83f346bc736898720a3d1521434f566a71bbeb7b4dc15"
3385
+ "sha256": "bc1526d068698e489fc2a698c04418e60097f18b56fd5c0dcdb6a6f62bbd1c81"
3386
3386
  },
3387
3387
  {
3388
3388
  "path": "modules/lifecycle/done-when.js",
@@ -3457,7 +3457,7 @@
3457
3457
  {
3458
3458
  "path": "modules/lifecycle/routes/claims.js",
3459
3459
  "mode": "0000644",
3460
- "sha256": "92065e0a5cfd2a5d76bbf04f4efafe0e2b3bc31cf616136963d4c07fad25047e"
3460
+ "sha256": "60f698b88fab3b850e2ab7d4350b7192ca57fa932089d0a24a187add08e5c819"
3461
3461
  },
3462
3462
  {
3463
3463
  "path": "modules/lifecycle/routes/dependencies.js",
@@ -4037,12 +4037,12 @@
4037
4037
  {
4038
4038
  "path": "package-lock.json",
4039
4039
  "mode": "0000644",
4040
- "sha256": "66f2a964b8eb493c69abb582b5f9be8dec20d49f31919abf3b55b8c11ffee2ed"
4040
+ "sha256": "90fef156fcc7117e3a88064eaab10da019b48fc2b1b5586cbbfe7d4006333682"
4041
4041
  },
4042
4042
  {
4043
4043
  "path": "package.json",
4044
4044
  "mode": "0000644",
4045
- "sha256": "c49444a432fa927ad7487c52180f23cda72e18055cfbfbf22565d4cdca2825de"
4045
+ "sha256": "9fd4475c4f673be84d4504811890f5ec5339a226e4c738d41b6bb94c808cc76e"
4046
4046
  },
4047
4047
  {
4048
4048
  "path": "public-docs/index.html",
@@ -5662,7 +5662,7 @@
5662
5662
  {
5663
5663
  "path": "src/module-api.js",
5664
5664
  "mode": "0000644",
5665
- "sha256": "f2df366234508aa746d344fd4c28b3e09f4354c3510f585283518a0fde03b89c"
5665
+ "sha256": "d55be57d6e2c4435e3b1cdab9543b90c449358b26ba1035fdb82b097f41ca7d6"
5666
5666
  },
5667
5667
  {
5668
5668
  "path": "src/module-loader/loader.js",
@@ -6742,7 +6742,7 @@
6742
6742
  {
6743
6743
  "path": "tests/infra_exec_paths.mjs",
6744
6744
  "mode": "0000644",
6745
- "sha256": "0437795b927bf68baa1e08ba2e52ca5b1799d41938402159ffb01f2f5bff01b2"
6745
+ "sha256": "2b905b00f873e082340af0fb6b460a31053d8b6cb77db121c97ed73383088973"
6746
6746
  },
6747
6747
  {
6748
6748
  "path": "tests/init.mjs",
@@ -7052,7 +7052,7 @@
7052
7052
  {
7053
7053
  "path": "tests/one_active_claim_per_session.mjs",
7054
7054
  "mode": "0000644",
7055
- "sha256": "3a4d6cc704d0ecd7be2f094ad10baf9c725a2045587994f076de76fb26ce90e5"
7055
+ "sha256": "030c7f3958d1fef45977326b52596e21638fe80b4ff9e7ecba31c76063283aea"
7056
7056
  },
7057
7057
  {
7058
7058
  "path": "tests/orphaned_claim_ship.mjs",
@@ -40,6 +40,10 @@ The default claim flow (`.claude/skills/builder-claim/SKILL.md`) ensures the tas
40
40
 
41
41
  > **Amended 2026-08-06 (task 1002570 / `core_204`).** The index as shipped was *stricter* than the worktree-scoped guard above: keyed on `creator_session_id` alone, it refused at the DB layer the second-worktree parallel claim the guard deliberately allows — breaking the many-sessions-per-builder norm this ADR set out to preserve. `migrations/<redacted>.sql` re-keys it to `(creator_session_id, COALESCE(worktree_name, ''))` under the name `uniq_active_claim_per_session_worktree`, same partial predicate, so the backstop now enforces exactly the guard's boundary. The `COALESCE` fold (rather than PG15's `NULLS NOT DISTINCT`) is load-bearing: a plain composite index treats NULLs as distinct, so two worktree-less claims off one shared clone would both insert and re-open the commingling hole. A `23505` from either index generation surfaces as `session_already_has_bound_claim` (task 1002589), never the false `ALREADY_CLAIMED`.
42
42
 
43
+ > **Amended 2026-08-11 (task 1002522) — the guard and the index now fold tree identity identically.** `core_204`'s key folds `NULL` and `''` together (`COALESCE(worktree_name, '')`); the guard compared raw values, where `'' !== null`. So a claim sent with `worktree_name: ""` (the route caps the length but never required non-empty) was waved through beside a worktree-less claim and *then* refused by the index — a `23505` on a task nobody had taken. `db.foldWorktreeName` is now the single normalization: it folds empty/whitespace-only to `NULL` and trims, and it is applied at **every ingest point** (`claimTask`, `claimTasksBatch`'s prefix, and `shouldRefuseSessionClaim`), so stored names are always trimmed-or-`NULL` and the guard's comparison of stored rows is exact by construction. Pinned DB-free in `tests/one_active_claim_per_session.mjs`.
44
+ >
45
+ > Two things were checked and deliberately left alone. **The stale-claim sweep needs no re-tuning** now that one session may hold N claims: both the heartbeat (`session_pulses`, PK `claim_id`) and the sweep (`modules/sessions/pulse.js sweepStaleClaims`) are keyed per *claim*, never per session, so each parallel worktree's claim ages out on its own clock. **One residual divergence remains**, tracked as [#1002709](https://example.com/builders#/task/1002709): V3.R51 disambiguation renames a colliding `worktree_name` (`"wt-a"` → `"wt-a-2"`) while the CLI keeps sending the original, so guard and index both key on a name the caller never used — a second claim in the *same* real tree can slip past both. Closing it needs the requested name persisted (a schema decision), which is why it is a follow-up rather than part of this amendment.
46
+
43
47
  ## Consequences
44
48
 
45
49
  - A session can hold at most one active claim — commingled ships are structurally prevented, not just discouraged.
package/docs/file-map.md CHANGED
@@ -226,6 +226,7 @@
226
226
  │ (public-status/ moved to modules/status-ui/public/ in BV1.R84 — see the modules/ block above)
227
227
  │ (public-builders/ moved to modules/hall-ui/public/ in BV1.R85 — the authenticated builders' hall; see the modules/ block above. It keeps its DEDICATED serving block in src/bongos/serve-internal.js: the builders.<apex> + apex /builders/* shim, canonical redirects, the citizen/Archon page gates, asset-stamping, and the module hall-widget injection — partitioned out of the generic web-surface loop. index.html=the hall dashboard; work/board/roadmap/goals/watch/harbor/gate/pair/settings/ranks/sessions/primer/diagrams/atlas pages; profile.html/profile.js=the public, shareable builder profile page (/profile?id=N — rank, works, credits, karma, achievements; task 1467); task.html/task.js=the task-detail page (/task/:id, task 751 — replaced the old #/task/N overlay); builders.js=the controller; dom-utils.js/hall-widgets.js=the widget+settings-panel registries.)
228
228
  │
229
+ ├── infra/ ← ON-BOX scripts (task 1002699): the four reporter/heartbeat crons every dev box runs from its /workspace clone — box-heartbeat.sh (idle-sweep activity signal), box-report-version.sh (HEAD → POST /box/version), box-report-host-keys.sh (SSH host keys → POST /box/host-keys), box-report-terminal.sh (ttyd URL+credential → POST /box/terminal). Delivered by the workspace clone itself (modules/dev-box/box-access.js sparse scope includes infra/); crons self-installed by box-source-fetch.sh's per-tick bring-up. API base resolves GDS_BASE env → session-file api_base → skip (no hardcoded host in the neutral core).
229
230
  ├── devbox-app/ ← OTB Dev Box — Electron menu-bar/tray switch for the builder's box (#904, ADR 0045; internal). SELF-CONTAINED: own package.json (never feeds the droplet's npm ci); built by .github/workflows/build-devbox-app.yml; published to the public releases repo per config/devbox-app.json; release recipe docs/recipes/devbox-app-release.md
230
231
  │ ├── src/ ← main process: main.js (tray/window/IPC), brand.js (ADR 0004 — the ONE civ-name string), pairing.js, box-controller.js, gds-client.js, session-store.js (shares ~/.config/otb/gds-session.json with the CLI), ssh-setup.js (key 600 + known_hosts replace + managed-settings install w/ elevation), tray.js, preload.js
231
232
  │ ├── renderer/ ← the panel UI (sandboxed; parchment theme)
@@ -447,7 +447,7 @@ const ENDPOINTS = [
447
447
  ['claimableTasks', [], 'GET', '/api/gds/tasks/claimable', undefined],
448
448
  ['claimTask', ['1002388'], 'POST', '/api/gds/claims', { task_id: '1002388' }],
449
449
  ['boxScope', [], 'GET', '/api/gds/box/scope', undefined],
450
- ['latestRelease', [], 'GET', '/downloads/devbox/latest.json', undefined],
450
+ ['latestRelease', [], 'GET', '/downloads/devbox/version', undefined],
451
451
  ];
452
452
 
453
453
  for (const [name, args, method, urlPath, body] of ENDPOINTS) {
@@ -335,6 +335,30 @@ function singleRungPromotionError(oldRank, newRank) {
335
335
  };
336
336
  }
337
337
 
338
+ // foldWorktreeName — task 1002522. The ONE normalization of an incoming
339
+ // worktree_name, applied at every ingest point so the app guard
340
+ // (shouldRefuseSessionClaim) and its DB backstop (core_204's index, keyed on
341
+ // (creator_session_id, COALESCE(worktree_name, ''))) can never disagree about
342
+ // which claims share a working tree.
343
+ //
344
+ // The index folds NULL to '' — so to Postgres an EMPTY-string name IS the
345
+ // worktree-less group. The guard compared raw values, where '' !== null: it
346
+ // waved through a ''-named claim beside a worktree-less one, and the INSERT
347
+ // then failed the backstop with a 23505 the caller never earned. Whitespace-only
348
+ // is the same case wearing a coat (`.claude/worktrees/<blank>` is the repo root,
349
+ // not a tree), so it folds too, and surrounding whitespace is trimmed rather
350
+ // than stored — an untrimmed name would key the index differently from the
351
+ // trimmed one the guard compared.
352
+ //
353
+ // Fold ONCE, at ingest: claimTask/claimTasksBatch normalize before the INSERT,
354
+ // so every stored name is already trimmed-or-NULL and the guard's comparison of
355
+ // stored rows is exact. Pure + exported for DB-free tests.
356
+ function foldWorktreeName(name) {
357
+ if (name == null) return null;
358
+ const trimmed = String(name).trim();
359
+ return trimmed === '' ? null : trimmed;
360
+ }
361
+
338
362
  // disambiguateWorktreeName — V3.R51 (#270). Two simultaneously-ACTIVE claims
339
363
  // must never share a worktree_name: `.claude/worktrees/<name>` is a filesystem
340
364
  // path, and two live builders pointing at the same one would stomp each other.
@@ -2762,7 +2786,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2762
2786
  // which is what the overnight builder relies on. We hold FOR UPDATE on the
2763
2787
  // task row already; reading the active names inside the same txn is good
2764
2788
  // enough for the disambiguation to be stable for this insert.
2765
- let finalWorktreeName = worktreeName ?? null;
2789
+ // task 1002522: fold ''/whitespace → NULL before anything keys on the name.
2790
+ // core_204's index folds them together anyway (COALESCE(worktree_name, ''));
2791
+ // normalizing here means the stored row matches what the guard compared.
2792
+ let finalWorktreeName = foldWorktreeName(worktreeName);
2766
2793
  if (finalWorktreeName) {
2767
2794
  const { rows: activeNameRows } = await client.query(
2768
2795
  `SELECT worktree_name FROM claims
@@ -3121,8 +3148,12 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
3121
3148
  );
3122
3149
  reservedMigrationNumber = bumpRows[0].allocated;
3123
3150
  }
3124
- const worktreeName = worktreePrefix
3125
- ? `${worktreePrefix}-${t.id}`
3151
+ // task 1002522: fold the prefix the same way a single claim's name is
3152
+ // folded, so a blank/whitespace prefix yields worktree-less rows rather
3153
+ // than names like " -1234" that the index would key as distinct trees.
3154
+ const foldedPrefix = foldWorktreeName(worktreePrefix);
3155
+ const worktreeName = foldedPrefix
3156
+ ? `${foldedPrefix}-${t.id}`
3126
3157
  : null;
3127
3158
  const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
3128
3159
  try {
@@ -5546,6 +5577,7 @@ module.exports = {
5546
5577
  countShippedTasksForBuilder,
5547
5578
  singleRungPromotionError,
5548
5579
  disambiguateWorktreeName,
5580
+ foldWorktreeName,
5549
5581
  updateTaskParallelSafe,
5550
5582
  updateTaskSecuritySensitive,
5551
5583
  updateTaskAutomationTag,
@@ -80,13 +80,17 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
80
80
  return { refuse: false, inFlightTaskId: null, inFlightTitle: null, reason: null };
81
81
  }
82
82
  const claims = Array.isArray(activeClaimsForSession) ? activeClaimsForSession : [];
83
- const incomingTree = worktreeName == null ? null : worktreeName;
84
- // Two claims share a WORKING TREE iff their worktree_names match, or both are
85
- // null (a worktree-less clone — the single branch genuinely commingles).
86
- const sameWorkingTree = (name) => {
87
- const t = name == null ? null : name;
88
- return t === incomingTree;
89
- };
83
+ // task 1002522: fold through db.foldWorktreeName — the SAME normalization the
84
+ // INSERT applies — so this decision keys trees exactly the way core_204's
85
+ // backstop index does (it folds NULL and '' into one group via
86
+ // COALESCE(worktree_name, '')). Comparing raw values let a ''-named claim slip
87
+ // past the guard and then trip the index, refusing a claim on a task that was
88
+ // never taken.
89
+ const incomingTree = db.foldWorktreeName(worktreeName);
90
+ // Two claims share a WORKING TREE iff their folded worktree_names match — which
91
+ // includes both being worktree-less (a plain clone, whose single branch
92
+ // genuinely commingles).
93
+ const sameWorkingTree = (name) => db.foldWorktreeName(name) === incomingTree;
90
94
  // Defence in depth: only count claims that belong to THIS session, are genuinely
91
95
  // session-bound (a NULL-session row must never trip the block), AND would land in
92
96
  // the SAME working tree as the incoming claim.
@@ -122,7 +126,9 @@ async function gateOneActiveClaimPerSession(req, res, next) {
122
126
  // The incoming claim's working tree — the hazard boundary the guard keys on. A
123
127
  // second claim in a DIFFERENT worktree is safe (different branch); only a second
124
128
  // claim into the SAME tree (or off one worktree-less clone) commingles.
125
- const worktreeName = req.body?.worktree_name ?? null;
129
+ // Folded (task 1002522) so the guard, the refusal message, and the row the
130
+ // INSERT will actually write all speak about the same tree.
131
+ const worktreeName = db.foldWorktreeName(req.body?.worktree_name);
126
132
  let activeClaimsForSession;
127
133
  try {
128
134
  activeClaimsForSession = await db.getActiveClaimsForSession(creatorSessionId);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.62",
3
+ "version": "1.19.64",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.62",
9
+ "version": "1.19.64",
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.62",
3
+ "version": "1.19.64",
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
@@ -551,8 +551,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
551
551
  // landed since 1.19.60 with no explicit bump. run 31550061440. (task 1002620)
552
552
  // 1.19.62 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
553
553
  // landed since 1.19.61 with no explicit bump. run 31552833331. (task 1002620)
554
+ // 1.19.63 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
555
+ // landed since 1.19.62 with no explicit bump. run 31553743979. (task 1002620)
556
+ // 1.19.64 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
557
+ // landed since 1.19.63 with no explicit bump. run 31560059785. (task 1002620)
554
558
  // ---------------------------------------------------------------------------
555
- const CORE_VERSION = '1.19.62'; // CI auto-patch carrier (ADR 0161); see changelog above
559
+ const CORE_VERSION = '1.19.64'; // CI auto-patch carrier (ADR 0161); see changelog above
556
560
 
557
561
  // A namespaced logger so a module's log lines are attributable + consistent.
558
562
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -31,18 +31,22 @@ function test(name, fn) {
31
31
  catch (err) { console.error(` FAIL ${name}`); console.error(' ', err.message); failed++; }
32
32
  }
33
33
 
34
- // infra/ is HOST topology (publish-manifest.js PROTECTED_TOPOLOGY_EXCLUDE) — the neutral
35
- // core carries no units and no launcher (task 1002470). The checks that read the REAL tree
36
- // only mean something where that tree exists; the synthetic counterfactuals below always run.
37
- const HAS_INFRA = fs.existsSync(path.join(REPO, 'infra'));
34
+ // Host topology means systemd UNITS, not the bare infra/ directory: the neutral core
35
+ // carries no units and no launcher (task 1002470), but it DOES ship the four ON-BOX
36
+ // reporter scripts in infra/ (task 1002699 — dev boxes clone this repo and wire them
37
+ // as crons). So the real-tree unit lint is gated on *.service files being present,
38
+ // and the launcher checks on core-node.sh — the synthetic counterfactuals always run.
39
+ const INFRA_DIR = path.join(REPO, 'infra');
40
+ const HAS_UNITS = fs.existsSync(INFRA_DIR) &&
41
+ fs.readdirSync(INFRA_DIR).some((f) => f.endsWith('.service'));
38
42
  const HAS_LAUNCHER = fs.existsSync(LAUNCHER);
39
43
  function hostTest(name, present, fn) {
40
- if (!present) { console.log(` skip ${name} — infra/ is absent in this checkout (host topology)`); return; }
44
+ if (!present) { console.log(` skip ${name} — host topology (units/launcher) absent in this checkout`); return; }
41
45
  test(name, fn);
42
46
  }
43
47
 
44
48
  // ---- A. the lint is green on the real tree, and covers all 11 core-script units ----------------
45
- hostTest('lint-infra-exec: every core-script unit resolves in a consumer layout (publishable + present)', HAS_INFRA, () => {
49
+ hostTest('lint-infra-exec: every core-script unit resolves in a consumer layout (publishable + present)', HAS_UNITS, () => {
46
50
  const r = lintInfraExec({ instanceRoot: REPO, coreRoot: REPO });
47
51
  assert.deepEqual(r.violations, [], `expected no violations, got: ${JSON.stringify(r.violations)}`);
48
52
  assert.ok(r.ok, 'lint should pass');
@@ -183,6 +183,80 @@ t('names the in-flight task in the MATCHING worktree, not the first row', () =>
183
183
  assert.equal(d.inFlightTitle, 'Two');
184
184
  });
185
185
 
186
+ // =========================================================================
187
+ // task 1002522 — the guard and core_204's backstop index must agree EXACTLY on
188
+ // which claims share a tree. The index keys (creator_session_id,
189
+ // COALESCE(worktree_name, '')), so to Postgres '' IS the worktree-less group;
190
+ // the guard compared raw values, where '' !== null. db.foldWorktreeName is the
191
+ // single normalization both sides now use (and claimTask applies before the
192
+ // INSERT, so stored rows are already trimmed-or-NULL).
193
+ // =========================================================================
194
+ const { foldWorktreeName } = require('../modules/lifecycle/db.js');
195
+
196
+ t('foldWorktreeName folds null / empty / whitespace to the same NULL key', () => {
197
+ assert.equal(foldWorktreeName(null), null);
198
+ assert.equal(foldWorktreeName(undefined), null);
199
+ assert.equal(foldWorktreeName(''), null);
200
+ assert.equal(foldWorktreeName(' '), null);
201
+ assert.equal(foldWorktreeName('\t\n'), null);
202
+ });
203
+
204
+ t('foldWorktreeName trims but never renames a real worktree', () => {
205
+ assert.equal(foldWorktreeName('wt-a'), 'wt-a');
206
+ assert.equal(foldWorktreeName(' wt-a '), 'wt-a');
207
+ // Distinct trees stay distinct — the fold must not collapse parallel work.
208
+ assert.notEqual(foldWorktreeName('wt-a'), foldWorktreeName('wt-a-2'));
209
+ });
210
+
211
+ t("empty-string worktree is the SAME tree as worktree-less → refused (matches the index)", () => {
212
+ // Before the fold this returned allow, and the INSERT then tripped
213
+ // uniq_active_claim_per_session_worktree — a 23505 on a task nobody had taken.
214
+ const d = shouldRefuseSessionClaim({
215
+ creatorSessionId: 'sess-6',
216
+ worktreeName: '',
217
+ activeClaimsForSession: [
218
+ { claim_id: 50, task_id: 70, creator_session_id: 'sess-6', worktree_name: null, title: 'shared clone' },
219
+ ],
220
+ });
221
+ assert.equal(d.refuse, true);
222
+ assert.equal(d.inFlightTaskId, 70);
223
+ });
224
+
225
+ t('the mirror case: worktree-less incoming vs a stored empty-string name → refused', () => {
226
+ const d = shouldRefuseSessionClaim({
227
+ creatorSessionId: 'sess-6',
228
+ worktreeName: null,
229
+ activeClaimsForSession: [
230
+ { claim_id: 51, task_id: 71, creator_session_id: 'sess-6', worktree_name: ' ', title: 'blank name' },
231
+ ],
232
+ });
233
+ assert.equal(d.refuse, true);
234
+ assert.equal(d.inFlightTaskId, 71);
235
+ });
236
+
237
+ t('whitespace around a real name does not fake a second tree', () => {
238
+ const d = shouldRefuseSessionClaim({
239
+ creatorSessionId: 'sess-6',
240
+ worktreeName: ' wt-a ',
241
+ activeClaimsForSession: [
242
+ { claim_id: 52, task_id: 72, creator_session_id: 'sess-6', worktree_name: 'wt-a', title: 'A' },
243
+ ],
244
+ });
245
+ assert.equal(d.refuse, true);
246
+ assert.equal(d.inFlightTaskId, 72);
247
+ });
248
+
249
+ t('the fold does NOT re-break parallel worktrees (the bug this family fixed)', () => {
250
+ const d = shouldRefuseSessionClaim({
251
+ creatorSessionId: 'sess-6',
252
+ worktreeName: 'wt-b',
253
+ activeClaimsForSession: [
254
+ { claim_id: 53, task_id: 73, creator_session_id: 'sess-6', worktree_name: 'wt-a', title: 'A' },
255
+ ],
256
+ });
257
+ assert.equal(d.refuse, false);
258
+ });
259
+
186
260
  // =========================================================================
187
261
  // task 1002589 (idea 1000638) — the DB backstop's ERROR SURFACE. A claims
188
262
  // INSERT can trip TWO partial unique indexes; claimTask used to map every pg