@bongos/core 1.19.63 → 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 +12 -12
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +4 -0
- package/modules/lifecycle/db.js +35 -3
- package/modules/lifecycle/routes/claims.js +14 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/one_active_claim_per_session.mjs +74 -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.64",
|
|
6
|
+
"core_contract": "1.19.64",
|
|
7
|
+
"source_commit": "ce3ba6421d4c20ca1f0b218a1b09d8825db932b1",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
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": "
|
|
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": "
|
|
950
|
+
"sha256": "3aa1e9074bd62168f999139533d672b0e0369186e2a78bbc966bca940df8d012"
|
|
951
951
|
},
|
|
952
952
|
{
|
|
953
953
|
"path": "docs/adr/0097-retroactive-reward-backfill.md",
|
|
@@ -3382,7 +3382,7 @@
|
|
|
3382
3382
|
{
|
|
3383
3383
|
"path": "modules/lifecycle/db.js",
|
|
3384
3384
|
"mode": "0000644",
|
|
3385
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
4040
|
+
"sha256": "90fef156fcc7117e3a88064eaab10da019b48fc2b1b5586cbbfe7d4006333682"
|
|
4041
4041
|
},
|
|
4042
4042
|
{
|
|
4043
4043
|
"path": "package.json",
|
|
4044
4044
|
"mode": "0000644",
|
|
4045
|
-
"sha256": "
|
|
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": "
|
|
5665
|
+
"sha256": "d55be57d6e2c4435e3b1cdab9543b90c449358b26ba1035fdb82b097f41ca7d6"
|
|
5666
5666
|
},
|
|
5667
5667
|
{
|
|
5668
5668
|
"path": "src/module-loader/loader.js",
|
|
@@ -7052,7 +7052,7 @@
|
|
|
7052
7052
|
{
|
|
7053
7053
|
"path": "tests/one_active_claim_per_session.mjs",
|
|
7054
7054
|
"mode": "0000644",
|
|
7055
|
-
"sha256": "
|
|
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/modules/lifecycle/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3125
|
-
|
|
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
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
@@ -553,8 +553,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
553
553
|
// landed since 1.19.61 with no explicit bump. run 31552833331. (task 1002620)
|
|
554
554
|
// 1.19.63 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
555
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)
|
|
556
558
|
// ---------------------------------------------------------------------------
|
|
557
|
-
const CORE_VERSION = '1.19.
|
|
559
|
+
const CORE_VERSION = '1.19.64'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
558
560
|
|
|
559
561
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
560
562
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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
|