@bongos/core 1.19.253 → 1.19.254
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 +25 -15
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +11 -1
- package/migrations/core_221_claims_requested_worktree_name.sql +144 -0
- package/modules/lifecycle/db.js +38 -10
- package/modules/lifecycle/routes/claims.js +20 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +3 -1
- package/tests/claims_requested_worktree_db.mjs +196 -0
- package/tests/one_active_claim_per_session.mjs +125 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.254",
|
|
6
|
+
"core_contract": "1.19.254",
|
|
7
|
+
"source_commit": "5955d399c73ae928fb94ecf67c3c1a59f2688dcd",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-19T02:09:24.775Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 315,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1347,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1684,
|
|
20
|
+
"tree_sha256": "a11f4b0d4a9487d13e0c01a48dcf28c80f4f008045403cc5388cb40c72bd3462",
|
|
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": "e06b7b9a18b669d1bc584ae0db73ccf49b47962580f632f8492113cca623bf37"
|
|
951
951
|
},
|
|
952
952
|
{
|
|
953
953
|
"path": "docs/adr/0097-retroactive-reward-backfill.md",
|
|
@@ -2399,6 +2399,11 @@
|
|
|
2399
2399
|
"mode": "0000644",
|
|
2400
2400
|
"sha256": "e0503f677b4b30eca07badd76ba9b177f603f3740da366c1be40cb67a5537690"
|
|
2401
2401
|
},
|
|
2402
|
+
{
|
|
2403
|
+
"path": "migrations/core_221_claims_requested_worktree_name.sql",
|
|
2404
|
+
"mode": "0000644",
|
|
2405
|
+
"sha256": "51d7fc76fdc59a5ab145839aa4943fe7753324022f82e4b4a76c2fe3cab9b98d"
|
|
2406
|
+
},
|
|
2402
2407
|
{
|
|
2403
2408
|
"path": "modules/autonomy/CLAUDE.md",
|
|
2404
2409
|
"mode": "0000644",
|
|
@@ -3702,7 +3707,7 @@
|
|
|
3702
3707
|
{
|
|
3703
3708
|
"path": "modules/lifecycle/db.js",
|
|
3704
3709
|
"mode": "0000644",
|
|
3705
|
-
"sha256": "
|
|
3710
|
+
"sha256": "b35b53b3badf7f465fe97d317cd32653518f14d98a76a03b18374c49b31db65a"
|
|
3706
3711
|
},
|
|
3707
3712
|
{
|
|
3708
3713
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3777,7 +3782,7 @@
|
|
|
3777
3782
|
{
|
|
3778
3783
|
"path": "modules/lifecycle/routes/claims.js",
|
|
3779
3784
|
"mode": "0000644",
|
|
3780
|
-
"sha256": "
|
|
3785
|
+
"sha256": "4f40c6e74c40c1ff29f8f487e2a6b574e7998aca4366a6372101d17412917eea"
|
|
3781
3786
|
},
|
|
3782
3787
|
{
|
|
3783
3788
|
"path": "modules/lifecycle/routes/dependencies.js",
|
|
@@ -4432,12 +4437,12 @@
|
|
|
4432
4437
|
{
|
|
4433
4438
|
"path": "package-lock.json",
|
|
4434
4439
|
"mode": "0000644",
|
|
4435
|
-
"sha256": "
|
|
4440
|
+
"sha256": "c880668f9420934895a29f1442f4db984dbaea35a93de7399f97353d6e5356f9"
|
|
4436
4441
|
},
|
|
4437
4442
|
{
|
|
4438
4443
|
"path": "package.json",
|
|
4439
4444
|
"mode": "0000644",
|
|
4440
|
-
"sha256": "
|
|
4445
|
+
"sha256": "80552b01c8d22ddfa1f5bf0e479dfe27c4e62e2e932555237f418bbe18edfd22"
|
|
4441
4446
|
},
|
|
4442
4447
|
{
|
|
4443
4448
|
"path": "public-docs/index.html",
|
|
@@ -5427,7 +5432,7 @@
|
|
|
5427
5432
|
{
|
|
5428
5433
|
"path": "scripts/gds/run-unit-tests.js",
|
|
5429
5434
|
"mode": "0000644",
|
|
5430
|
-
"sha256": "
|
|
5435
|
+
"sha256": "e2e3cd6299f63b838ed5a78121eedcb44e76b3cc62b622581cd537c154b1d8ae"
|
|
5431
5436
|
},
|
|
5432
5437
|
{
|
|
5433
5438
|
"path": "scripts/gds/sandbox-stage.js",
|
|
@@ -6057,7 +6062,7 @@
|
|
|
6057
6062
|
{
|
|
6058
6063
|
"path": "src/module-api.js",
|
|
6059
6064
|
"mode": "0000644",
|
|
6060
|
-
"sha256": "
|
|
6065
|
+
"sha256": "0f52e5cbb0bc88abd77cdc790b563c8a2766ed6d4a5a73240343b15655f07442"
|
|
6061
6066
|
},
|
|
6062
6067
|
{
|
|
6063
6068
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6509,6 +6514,11 @@
|
|
|
6509
6514
|
"mode": "0000644",
|
|
6510
6515
|
"sha256": "d8d95c67c6c746418716b384fc46eefbf5187686f091353258768b83d5c44519"
|
|
6511
6516
|
},
|
|
6517
|
+
{
|
|
6518
|
+
"path": "tests/claims_requested_worktree_db.mjs",
|
|
6519
|
+
"mode": "0000644",
|
|
6520
|
+
"sha256": "977e10f8a92acf20afa58c01bd2d0b68d64414ac7d2a1d15a4d22c332be75f36"
|
|
6521
|
+
},
|
|
6512
6522
|
{
|
|
6513
6523
|
"path": "tests/claude_materialize.mjs",
|
|
6514
6524
|
"mode": "0000644",
|
|
@@ -7667,7 +7677,7 @@
|
|
|
7667
7677
|
{
|
|
7668
7678
|
"path": "tests/one_active_claim_per_session.mjs",
|
|
7669
7679
|
"mode": "0000644",
|
|
7670
|
-
"sha256": "
|
|
7680
|
+
"sha256": "f4da7e663ab6d7b93bb02ecefd18178fa654a314da07108c4316317f50115ad0"
|
|
7671
7681
|
},
|
|
7672
7682
|
{
|
|
7673
7683
|
"path": "tests/orphaned_claim_ship.mjs",
|
|
@@ -42,11 +42,21 @@ The default claim flow (`.claude/skills/builder-claim/SKILL.md`) ensures the tas
|
|
|
42
42
|
|
|
43
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
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.
|
|
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. **That follow-up landed — see the next amendment.**
|
|
46
46
|
|
|
47
47
|
## Consequences
|
|
48
48
|
|
|
49
49
|
- A session can hold at most one active claim — commingled ships are structurally prevented, not just discouraged.
|
|
50
|
+
> **Amended 2026-08-19 (task 1002709 / `core_221`) — the residual divergence above is closed: the guard keys on the REQUESTED worktree name.** V3.R51 disambiguation silently renames a colliding `worktree_name` (`"overnight"` → `"overnight-2"`), which is correct and load-bearing — `.claude/worktrees/<name>` is a filesystem path and two live builders pointing at one would stomp each other. But both halves of the rule then keyed on the **stored** name while `scripts/gds/claim.js` keeps sending the **real directory** name, which is never renamed. So a second claim from the same session in the same tree compared `"overnight"` against the stored `"overnight-2"`, read them as two trees, and was allowed by the guard *and* the index: one working tree, one branch, two active claims — precisely the commingled ship this ADR exists to prevent. Reachable for any fixed reusable name (the overnight builder, box workflows, a hand-passed `--worktree`).
|
|
51
|
+
>
|
|
52
|
+
> `claims.requested_worktree_name` now stores what the caller asked for, and the guard (`shouldRefuseSessionClaim`) plus the re-keyed index `uniq_active_claim_per_session_requested_worktree` both key on it. **Two names, two jobs, and keeping them apart is the point:** `worktree_name` is the disambiguated name on disk and stays globally unique across active claims (branch naming and the operator surfaces read it); `requested_worktree_name` is the identity of the working *tree*, which is what "do these two claims share a tree" actually asks.
|
|
53
|
+
>
|
|
54
|
+
> **Why a stored column and not a cleverer guard.** Matching `^<requested>-\d+$` would refuse a builder genuinely running worktrees `foo` and `foo-2` side by side — reintroducing exactly the false refusal this family of amendments (1002570, 1002589, 1002522) keeps having to undo. The guard needs the name the claim was *filed* under, and that was simply never recorded.
|
|
55
|
+
>
|
|
56
|
+
> **The migration deliberately UNDER-heals, and that is a decision rather than an oversight.** After the backfill copies `worktree_name` across, a pre-existing commingled pair (stored `overnight` + `overnight-2`, one real tree) and a legitimate pair (stored `foo` + `foo-2`, two real trees) are **indistinguishable** — the requested name was never recorded, which is the whole bug. Any heuristic able to close the first would also silently close the second, killing an active claim of a builder who did nothing wrong, and a migration acts without asking. So pairs already in flight stay in flight (the pre-existing hazard, unchanged: the first to ship takes the branch), and no *new* pair can be created from the moment the guard lands. The heal still earns its place for rows the new code writes, where a genuine collision on the requested name is possible and would otherwise block `CREATE UNIQUE INDEX` on deploy.
|
|
57
|
+
>
|
|
58
|
+
> All three index generations stay in `claimUniqueViolationError`'s `SESSION_CLAIM_INDEXES`, because a deploy applies code and migration at different instants and an unrecognised constraint falls closed onto the generic `ALREADY_CLAIMED` — the phantom-rival answer task 1002589 removed. The guard falls back to `worktree_name` when `requested_worktree_name` is NULL, which keeps it correct in that same window. Pinned DB-free in `tests/one_active_claim_per_session.mjs` (including the three-step repro, which fails against the old code) and against a real Postgres in `tests/claims_requested_worktree_db.mjs`.
|
|
59
|
+
|
|
50
60
|
- The many-sessions-per-builder norm (ADR 0049's `feedback_parallel_sessions_not_anomaly`) is preserved exactly: each worktree/session still gets its own claim.
|
|
51
61
|
- Web claims (NULL session) are unaffected — claim-in-browser, ship-from-any-terminal still works.
|
|
52
62
|
- This composes with, and does not alter, ADR 0049's split cross-claim contract: the per-task index, the advisory `touches[]` warning, and the autonomous-runner footprint gate are all untouched. This ADR only adds the within-session invariant 0049 left implicit.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
-- core_221_claims_requested_worktree_name.sql
|
|
2
|
+
-- Task 1002709 (goal 1000024, build-pipeline integrity). A CORE migration, not a
|
|
3
|
+
-- lifecycle_ one, by the same rule core_204 states: it alters the shared
|
|
4
|
+
-- pre-module `claims` table and swaps an index on it, and a module migration
|
|
5
|
+
-- must stay additive within its own <key>_ tables (ADR 0083 §Decision #5).
|
|
6
|
+
-- Idempotent on re-run.
|
|
7
|
+
--
|
|
8
|
+
-- ── THE HOLE ────────────────────────────────────────────────────────────────
|
|
9
|
+
--
|
|
10
|
+
-- V3.R51 disambiguation (modules/lifecycle/db.js disambiguateWorktreeName)
|
|
11
|
+
-- silently RENAMES a requested worktree_name that collides with any other
|
|
12
|
+
-- currently-active claim: "overnight" is stored as "overnight-2". That rename is
|
|
13
|
+
-- correct and load-bearing — `.claude/worktrees/<name>` is a filesystem path and
|
|
14
|
+
-- two live builders pointing at one would stomp each other.
|
|
15
|
+
--
|
|
16
|
+
-- But both halves of the one-claim-per-tree rule then key on the STORED name,
|
|
17
|
+
-- while the CLI keeps sending the REQUESTED one (scripts/gds/claim.js reads the
|
|
18
|
+
-- real directory, which is never renamed). So:
|
|
19
|
+
--
|
|
20
|
+
-- 1. Builder X holds an active claim with worktree_name "overnight".
|
|
21
|
+
-- 2. Builder Y, session S, in a tree genuinely named "overnight", claims task
|
|
22
|
+
-- A. Y's name collides with X's, so it is stored as "overnight-2".
|
|
23
|
+
-- 3. Same session S, same tree, claims task B. The guard compares the incoming
|
|
24
|
+
-- "overnight" against the stored "overnight-2" — different tree, ALLOW. The
|
|
25
|
+
-- index sees (S,"overnight") vs (S,"overnight-2") — no collision, INSERT.
|
|
26
|
+
--
|
|
27
|
+
-- Result: ONE real working tree, ONE branch, TWO active claims — the exact
|
|
28
|
+
-- commingled-ship hazard task 1642 and ADR 0097 exist to prevent. Rare, because
|
|
29
|
+
-- worktree names are usually task-slug + random hex, but reachable for any fixed
|
|
30
|
+
-- reusable name: the overnight builder, box workflows, `--worktree` passed by
|
|
31
|
+
-- hand.
|
|
32
|
+
--
|
|
33
|
+
-- ── WHY A COLUMN AND NOT A CLEVERER GUARD ───────────────────────────────────
|
|
34
|
+
--
|
|
35
|
+
-- Matching "^<requested>-\d+$" in the guard would refuse a builder who genuinely
|
|
36
|
+
-- runs worktrees "foo" and "foo-2" side by side — reintroducing the false
|
|
37
|
+
-- refusal this whole family of bugs (1002570, 1002589, 1002522) is about. The
|
|
38
|
+
-- guard needs the name the claim was FILED under, which is simply not stored
|
|
39
|
+
-- today. So store it.
|
|
40
|
+
--
|
|
41
|
+
-- TWO NAMES, TWO JOBS, and keeping them separate is the point:
|
|
42
|
+
-- worktree_name the name on DISK after disambiguation. Still what
|
|
43
|
+
-- branch naming and the operator surfaces read, and
|
|
44
|
+
-- still globally unique across active claims.
|
|
45
|
+
-- requested_worktree_name the name the CALLER asked for. The identity of the
|
|
46
|
+
-- working TREE, which is what "do these two claims
|
|
47
|
+
-- share a tree" actually asks.
|
|
48
|
+
--
|
|
49
|
+
-- ── THE BACKFILL IS HONEST ABOUT WHAT IT CANNOT KNOW ────────────────────────
|
|
50
|
+
--
|
|
51
|
+
-- For historical rows the requested name was never recorded. Copying
|
|
52
|
+
-- worktree_name across is exactly right for every row that was NOT renamed (the
|
|
53
|
+
-- overwhelming majority — disambiguation only fires on a live collision) and is
|
|
54
|
+
-- the best available answer for the few that were. It cannot resurrect a lost
|
|
55
|
+
-- fact, and it does not pretend to: the column is nullable, and a row that was
|
|
56
|
+
-- renamed simply carries its post-rename name as its pre-rename one. No active
|
|
57
|
+
-- claim changes meaning, because the pair is only compared against other rows
|
|
58
|
+
-- backfilled the same way.
|
|
59
|
+
|
|
60
|
+
BEGIN;
|
|
61
|
+
|
|
62
|
+
ALTER TABLE claims
|
|
63
|
+
ADD COLUMN IF NOT EXISTS requested_worktree_name text;
|
|
64
|
+
|
|
65
|
+
COMMENT ON COLUMN claims.requested_worktree_name IS
|
|
66
|
+
'The worktree name the CALLER asked for, before V3.R51 disambiguation renamed it. The one-claim-per-working-tree guard keys on THIS (task 1002709); worktree_name remains the disambiguated on-disk name.';
|
|
67
|
+
|
|
68
|
+
-- Backfill (see the header): the requested name equals the stored name for every
|
|
69
|
+
-- row disambiguation never touched, and is the closest available answer for the
|
|
70
|
+
-- rest. Guarded so a re-run never overwrites a real value with a stale one.
|
|
71
|
+
UPDATE claims
|
|
72
|
+
SET requested_worktree_name = worktree_name
|
|
73
|
+
WHERE requested_worktree_name IS NULL;
|
|
74
|
+
|
|
75
|
+
-- ── heal before CREATE (the migration 174/180/204 shape) ────────────────────
|
|
76
|
+
--
|
|
77
|
+
-- A unique index refuses to create if live rows already violate it, which would
|
|
78
|
+
-- block the deploy. This heal keeps that safe for rows the NEW code writes.
|
|
79
|
+
--
|
|
80
|
+
-- WHAT IT CANNOT DO, STATED PLAINLY: it cannot find a commingled pair that
|
|
81
|
+
-- already exists. Consider the two shapes, after the backfill above:
|
|
82
|
+
--
|
|
83
|
+
-- commingled session S, stored "overnight" + "overnight-2", ONE real tree
|
|
84
|
+
-- legitimate session S, stored "foo" + "foo-2", TWO real trees
|
|
85
|
+
--
|
|
86
|
+
-- Both backfill to requested = their stored name, so both read as two distinct
|
|
87
|
+
-- trees, and the heal closes neither. They are INDISTINGUISHABLE in the data —
|
|
88
|
+
-- the requested name was never recorded, which is the entire bug. Any heuristic
|
|
89
|
+
-- that could close the first (stripping a "-\d+" suffix) would also close the
|
|
90
|
+
-- second, silently killing an active claim of a builder who did nothing wrong.
|
|
91
|
+
-- That is the same false-refusal trap that makes suffix-matching wrong in the
|
|
92
|
+
-- guard, and it is worse here because a migration acts without asking.
|
|
93
|
+
--
|
|
94
|
+
-- So this deliberately under-heals. The window it leaves is bounded and small:
|
|
95
|
+
-- any commingled pair already in flight stays in flight, and the FIRST of the
|
|
96
|
+
-- two to ship takes the tree's branch — the pre-existing hazard, unchanged. From
|
|
97
|
+
-- the moment the new guard code lands, no NEW pair can be created. Closing the
|
|
98
|
+
-- ledger honestly beats closing someone's claim on a guess.
|
|
99
|
+
--
|
|
100
|
+
-- The heal still earns its place: once rows carry a real requested name, two of
|
|
101
|
+
-- them CAN collide on it, and then this closes the later and keeps the earliest
|
|
102
|
+
-- — the same rule core_204 applied, and the one the guard would have applied had
|
|
103
|
+
-- it been able to see the collision at claim time.
|
|
104
|
+
WITH ranked AS (
|
|
105
|
+
SELECT c.id,
|
|
106
|
+
ROW_NUMBER() OVER (
|
|
107
|
+
PARTITION BY c.creator_session_id, COALESCE(c.requested_worktree_name, '')
|
|
108
|
+
ORDER BY c.claimed_at, c.id
|
|
109
|
+
) AS rn
|
|
110
|
+
FROM claims c
|
|
111
|
+
WHERE c.released_at IS NULL
|
|
112
|
+
AND c.creator_session_id IS NOT NULL
|
|
113
|
+
)
|
|
114
|
+
UPDATE claims c
|
|
115
|
+
SET released_at = now(),
|
|
116
|
+
outcome = COALESCE(c.outcome, 'abandoned')
|
|
117
|
+
FROM ranked r
|
|
118
|
+
WHERE c.id = r.id
|
|
119
|
+
AND r.rn > 1;
|
|
120
|
+
|
|
121
|
+
-- ── swap the index ──────────────────────────────────────────────────────────
|
|
122
|
+
-- COALESCE, not PG15's NULLS NOT DISTINCT, and it is load-bearing for the same
|
|
123
|
+
-- reason core_204 gives: in a plain multi-column unique index NULLs are
|
|
124
|
+
-- DISTINCT, so two worktree-less claims (one shared clone, whose single branch
|
|
125
|
+
-- genuinely commingles) would both insert and re-open the hole task 1642 closed.
|
|
126
|
+
--
|
|
127
|
+
-- The old index is DROPPED rather than left alongside. Keeping it would re-refuse
|
|
128
|
+
-- a legitimate second claim from a builder genuinely running "foo" and "foo-2"
|
|
129
|
+
-- in parallel — the false refusal this change exists to avoid reintroducing.
|
|
130
|
+
DROP INDEX IF EXISTS uniq_active_claim_per_session_worktree;
|
|
131
|
+
|
|
132
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_requested_worktree
|
|
133
|
+
ON claims (creator_session_id, (COALESCE(requested_worktree_name, '')))
|
|
134
|
+
WHERE released_at IS NULL AND creator_session_id IS NOT NULL;
|
|
135
|
+
|
|
136
|
+
-- (This index name and claimUniqueViolationError's SESSION_CLAIM_INDEXES set
|
|
137
|
+
-- must stay in lockstep — tests/one_active_claim_per_session.mjs pins both
|
|
138
|
+
-- sides, and all three generations stay in that set because a deploy applies
|
|
139
|
+
-- code and migration at different instants.)
|
|
140
|
+
|
|
141
|
+
INSERT INTO schema_migrations (version) VALUES ('core_221_claims_requested_worktree_name')
|
|
142
|
+
ON CONFLICT (version) DO NOTHING;
|
|
143
|
+
|
|
144
|
+
COMMIT;
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -2600,7 +2600,13 @@ async function unsatisfiedDeps(taskId, client = pool) {
|
|
|
2600
2600
|
// carry the old index.
|
|
2601
2601
|
const SESSION_CLAIM_INDEXES = new Set([
|
|
2602
2602
|
'uniq_active_claim_per_session', // migration 180 — session-only (pre-re-key)
|
|
2603
|
-
'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, worktree)
|
|
2603
|
+
'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, stored worktree)
|
|
2604
|
+
// core_221 (task 1002709) — per (session, REQUESTED worktree). All three
|
|
2605
|
+
// generations stay listed: a deploy applies code and migration at different
|
|
2606
|
+
// instants, so an instance may still carry an older index, and an unrecognised
|
|
2607
|
+
// constraint falls closed onto the generic ALREADY_CLAIMED — which for this
|
|
2608
|
+
// case is the wrong, phantom-rival answer.
|
|
2609
|
+
'uniq_active_claim_per_session_requested_worktree',
|
|
2604
2610
|
]);
|
|
2605
2611
|
// Branch on err.constraint; an UNKNOWN constraint fails CLOSED on the generic
|
|
2606
2612
|
// ALREADY_CLAIMED (never widen a claim refusal). Pure + exported for DB-free
|
|
@@ -2830,7 +2836,18 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2830
2836
|
// task 1002522: fold ''/whitespace → NULL before anything keys on the name.
|
|
2831
2837
|
// core_204's index folds them together anyway (COALESCE(worktree_name, ''));
|
|
2832
2838
|
// normalizing here means the stored row matches what the guard compared.
|
|
2833
|
-
|
|
2839
|
+
// task 1002709: BOTH names are stored, and they answer different questions.
|
|
2840
|
+
// `requestedWorktreeName` is what the caller asked for — the identity of the
|
|
2841
|
+
// working TREE, which is what "do these two claims share a tree" actually
|
|
2842
|
+
// asks. `finalWorktreeName` is what lands on disk after disambiguation, which
|
|
2843
|
+
// is what must stay globally unique across active claims.
|
|
2844
|
+
//
|
|
2845
|
+
// Keying the one-claim-per-tree guard on the STORED name was the hole: a
|
|
2846
|
+
// second claim from the same session in the same tree compared "overnight"
|
|
2847
|
+
// against a previously-renamed "overnight-2", saw a different tree, and let
|
|
2848
|
+
// a commingling claim through. See core_221 for the full repro.
|
|
2849
|
+
const requestedWorktreeName = foldWorktreeName(worktreeName);
|
|
2850
|
+
let finalWorktreeName = requestedWorktreeName;
|
|
2834
2851
|
if (finalWorktreeName) {
|
|
2835
2852
|
const { rows: activeNameRows } = await client.query(
|
|
2836
2853
|
`SELECT worktree_name FROM claims
|
|
@@ -2841,10 +2858,10 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2841
2858
|
}
|
|
2842
2859
|
|
|
2843
2860
|
const insertClaim = await client.query(
|
|
2844
|
-
`INSERT INTO claims (task_id, builder_id, worktree_name, head_sha, reserved_migration_number, creator_session_id)
|
|
2845
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
2846
|
-
RETURNING id, task_id, builder_id, worktree_name, claimed_at, head_sha, reserved_migration_number`,
|
|
2847
|
-
[taskId, builderId, finalWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
|
|
2861
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
|
|
2862
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
2863
|
+
RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
|
|
2864
|
+
[taskId, builderId, finalWorktreeName, requestedWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
|
|
2848
2865
|
);
|
|
2849
2866
|
|
|
2850
2867
|
await client.query(`UPDATE tasks SET status = 'active' WHERE id = $1`, [taskId]);
|
|
@@ -3198,10 +3215,16 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
3198
3215
|
: null;
|
|
3199
3216
|
const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
|
|
3200
3217
|
try {
|
|
3218
|
+
// task 1002709: requested == final on this path by construction — each
|
|
3219
|
+
// row gets its own `<prefix>-<taskId>` tree and nothing disambiguates
|
|
3220
|
+
// them. It is still written explicitly rather than left NULL: core_221's
|
|
3221
|
+
// index keys on COALESCE(requested_worktree_name, ''), so a NULL here
|
|
3222
|
+
// would make a row that HAS a worktree read as worktree-less and collide
|
|
3223
|
+
// with the shared-clone group.
|
|
3201
3224
|
const insertClaim = await client.query(
|
|
3202
|
-
`INSERT INTO claims (task_id, builder_id, worktree_name, head_sha, reserved_migration_number, creator_session_id)
|
|
3203
|
-
VALUES ($1, $2, $3, NULL, $4, $5)
|
|
3204
|
-
RETURNING id, task_id, builder_id, worktree_name, claimed_at, head_sha, reserved_migration_number`,
|
|
3225
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
|
|
3226
|
+
VALUES ($1, $2, $3, $3, NULL, $4, $5)
|
|
3227
|
+
RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
|
|
3205
3228
|
[t.id, builderId, worktreeName, reservedMigrationNumber, rowCreatorSessionId]
|
|
3206
3229
|
);
|
|
3207
3230
|
inserted.push(insertClaim.rows[0]);
|
|
@@ -4843,7 +4866,12 @@ async function activeClaimCountForBuilder(builderId, deps = {}) {
|
|
|
4843
4866
|
async function getActiveClaimsForSession(creatorSessionId) {
|
|
4844
4867
|
if (!creatorSessionId) return [];
|
|
4845
4868
|
const { rows } = await pool.query(
|
|
4846
|
-
|
|
4869
|
+
// task 1002709: requested_worktree_name rides along because the guard keys on
|
|
4870
|
+
// it — comparing the incoming REQUESTED name against a stored DISAMBIGUATED
|
|
4871
|
+
// one is what let a second same-tree claim through. worktree_name stays
|
|
4872
|
+
// selected: callers still render the on-disk name.
|
|
4873
|
+
`SELECT c.id AS claim_id, c.task_id, c.creator_session_id, c.worktree_name,
|
|
4874
|
+
c.requested_worktree_name, c.claimed_at,
|
|
4847
4875
|
t.title
|
|
4848
4876
|
FROM claims c JOIN tasks t ON t.id = c.task_id
|
|
4849
4877
|
WHERE c.creator_session_id = $1 AND c.released_at IS NULL
|
|
@@ -90,13 +90,31 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
|
|
|
90
90
|
// Two claims share a WORKING TREE iff their folded worktree_names match — which
|
|
91
91
|
// includes both being worktree-less (a plain clone, whose single branch
|
|
92
92
|
// genuinely commingles).
|
|
93
|
-
|
|
93
|
+
//
|
|
94
|
+
// task 1002709: compare against the REQUESTED name, not the stored one. V3.R51
|
|
95
|
+
// silently renames a colliding worktree_name ("overnight" → "overnight-2"), and
|
|
96
|
+
// the CLI keeps sending the real directory name, which is never renamed — so
|
|
97
|
+
// keying on the stored name compared "overnight" against "overnight-2", read
|
|
98
|
+
// them as different trees, and let a SECOND claim into the SAME tree. One tree,
|
|
99
|
+
// one branch, two active claims: exactly the commingled ship this guard exists
|
|
100
|
+
// to stop. See core_221 for the three-step repro.
|
|
101
|
+
//
|
|
102
|
+
// requested_worktree_name is NULL on rows written before core_221's backfill
|
|
103
|
+
// ran, so fall back to worktree_name — which for any row disambiguation never
|
|
104
|
+
// touched IS the requested name, and is the honest best answer for the rest. A
|
|
105
|
+
// deploy applies code and migration at different instants; this fallback is
|
|
106
|
+
// what keeps the guard correct in that window rather than reading every old
|
|
107
|
+
// row as worktree-less.
|
|
108
|
+
const treeOf = (c) => db.foldWorktreeName(
|
|
109
|
+
c.requested_worktree_name ?? c.worktree_name ?? null
|
|
110
|
+
);
|
|
111
|
+
const sameWorkingTree = (claim) => treeOf(claim) === incomingTree;
|
|
94
112
|
// Defence in depth: only count claims that belong to THIS session, are genuinely
|
|
95
113
|
// session-bound (a NULL-session row must never trip the block), AND would land in
|
|
96
114
|
// the SAME working tree as the incoming claim.
|
|
97
115
|
const inFlight = claims.find(
|
|
98
116
|
(c) => c && c.creator_session_id != null && c.creator_session_id === creatorSessionId
|
|
99
|
-
&& sameWorkingTree(c
|
|
117
|
+
&& sameWorkingTree(c)
|
|
100
118
|
);
|
|
101
119
|
if (inFlight) {
|
|
102
120
|
return {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.254",
|
|
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.254",
|
|
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.254",
|
|
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",
|
|
@@ -73,7 +73,14 @@ const INTEGRATION = new Set([
|
|
|
73
73
|
// platform-server — network + package build + a spawned server, run by its own
|
|
74
74
|
// .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
|
|
75
75
|
'consumer_layout_boot',
|
|
76
|
-
'claim_gate_open_blocker', 'claim_gate_rebase',
|
|
76
|
+
'claim_gate_open_blocker', 'claim_gate_rebase',
|
|
77
|
+
// task 1002709: core_221's re-keyed backstop index against a real Postgres.
|
|
78
|
+
// The DB-free sibling pins the guard and the migration's text; this proves the
|
|
79
|
+
// index actually refuses the commingling INSERT and still allows the parallel
|
|
80
|
+
// -worktree case. It self-skips without a DB, and refuses any database that
|
|
81
|
+
// has a `builders` table, since it TRUNCATEs claims.
|
|
82
|
+
'claims_requested_worktree_db',
|
|
83
|
+
'clawback_confirmed_backlog',
|
|
77
84
|
// task 1003113: the copy_desk_flags schema half. The module's own tests are
|
|
78
85
|
// DB-free and cover the pure layer; the rules that MATTER here live in the
|
|
79
86
|
// schema — a CHECK-constraint reason floor whose whole argument is that no
|
package/src/module-api.js
CHANGED
|
@@ -933,8 +933,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
933
933
|
// landed since 1.19.251 with no explicit bump. run 32205191350. (task 1002620)
|
|
934
934
|
// 1.19.253 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
935
935
|
// landed since 1.19.252 with no explicit bump. run 32205833181. (task 1002620)
|
|
936
|
+
// 1.19.254 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
937
|
+
// landed since 1.19.253 with no explicit bump. run 32207504942. (task 1002620)
|
|
936
938
|
// ---------------------------------------------------------------------------
|
|
937
|
-
const CORE_VERSION = '1.19.
|
|
939
|
+
const CORE_VERSION = '1.19.254'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
938
940
|
|
|
939
941
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
940
942
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/claims_requested_worktree_db.mjs — core_221's re-keyed backstop index
|
|
2
|
+
// against a REAL Postgres (task 1002709).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS BESIDE tests/one_active_claim_per_session.mjs. That file is
|
|
5
|
+
// DB-free and covers the app GUARD plus text tripwires on the migration's shape.
|
|
6
|
+
// It cannot cover the half that is a claim about the database: whether the
|
|
7
|
+
// re-keyed unique index actually refuses the commingling INSERT, whether it
|
|
8
|
+
// still ALLOWS the legitimate parallel-worktree case, and whether the migration
|
|
9
|
+
// survives being applied over the very row shape the bug produced.
|
|
10
|
+
//
|
|
11
|
+
// The guard and the index are defence in depth for the same rule, and this
|
|
12
|
+
// family of bugs (1002570, 1002589, 1002522, and now 1002709) has been almost
|
|
13
|
+
// entirely about the two disagreeing. So both halves get their own proof.
|
|
14
|
+
//
|
|
15
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
16
|
+
// run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it.
|
|
17
|
+
//
|
|
18
|
+
// Run: DATABASE_URL=postgres://... node tests/claims_requested_worktree_db.mjs
|
|
19
|
+
//
|
|
20
|
+
// It builds its own throwaway `claims` table when one is absent, and truncates
|
|
21
|
+
// only that table — so point it at a scratch database, never a real one.
|
|
22
|
+
|
|
23
|
+
import pg from 'pg';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import url from 'node:url';
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
|
|
29
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await pool.query('SELECT 1');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`claims_requested_worktree_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// This test TRUNCATEs `claims`. Refuse to run against anything that looks like a
|
|
40
|
+
// real database — a scratch container is the only sane target, and an accidental
|
|
41
|
+
// DATABASE_URL pointing at prod would otherwise wipe the claims ledger.
|
|
42
|
+
const { rows: realRows } = await pool.query(
|
|
43
|
+
`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
44
|
+
if (realRows[0].has_builders) {
|
|
45
|
+
console.log('claims_requested_worktree_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB. It TRUNCATEs `claims`; point it at a throwaway Postgres.');
|
|
46
|
+
await pool.end().catch(() => {});
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
let passed = 0, failed = 0;
|
|
50
|
+
async function t(name, fn) {
|
|
51
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
52
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await pool.query(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
58
|
+
id bigserial PRIMARY KEY,
|
|
59
|
+
task_id bigint, builder_id bigint,
|
|
60
|
+
worktree_name text,
|
|
61
|
+
creator_session_id text,
|
|
62
|
+
claimed_at timestamptz DEFAULT now(),
|
|
63
|
+
released_at timestamptz,
|
|
64
|
+
outcome text);
|
|
65
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree
|
|
66
|
+
ON claims (creator_session_id, (COALESCE(worktree_name, '')))
|
|
67
|
+
WHERE released_at IS NULL AND creator_session_id IS NOT NULL;
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
// Seed the PRE-migration commingled state: one session, one real tree, two
|
|
71
|
+
// active claims that were only ever distinguished by the suffix. This is the row
|
|
72
|
+
// shape the bug produced, and the migration has to heal it before it can build
|
|
73
|
+
// the unique index.
|
|
74
|
+
await pool.query(`TRUNCATE claims`);
|
|
75
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, creator_session_id, claimed_at) VALUES
|
|
76
|
+
(1, 1, 'overnight', 'sess-A', now() - interval '2 hour'),
|
|
77
|
+
(2, 1, 'overnight-2', 'sess-A', now() - interval '1 hour')`);
|
|
78
|
+
|
|
79
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
80
|
+
const MIG = fs.readFileSync(
|
|
81
|
+
path.join(HERE, '..', 'migrations', 'core_221_claims_requested_worktree_name.sql'), 'utf8');
|
|
82
|
+
|
|
83
|
+
console.log('core_221 against real Postgres');
|
|
84
|
+
|
|
85
|
+
await t('the migration applies over the commingled state (heals, then indexes)', async () => {
|
|
86
|
+
await pool.query(MIG);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('it deliberately UNDER-heals a pre-existing commingled pair, and does not guess', async () => {
|
|
90
|
+
// The honest limitation, asserted rather than hoped for. After the backfill,
|
|
91
|
+
// a commingled pair (stored "overnight" + "overnight-2", ONE real tree) and a
|
|
92
|
+
// legitimate pair (stored "foo" + "foo-2", TWO real trees) are
|
|
93
|
+
// INDISTINGUISHABLE — the requested name was never recorded, which is the
|
|
94
|
+
// whole bug. Suffix-stripping could close the first but would also silently
|
|
95
|
+
// kill the second, so the migration closes neither.
|
|
96
|
+
const { rows } = await pool.query(
|
|
97
|
+
`SELECT task_id, released_at FROM claims ORDER BY claimed_at`);
|
|
98
|
+
assert.equal(rows.length, 2);
|
|
99
|
+
assert.equal(rows[0].released_at, null);
|
|
100
|
+
assert.equal(rows[1].released_at, null,
|
|
101
|
+
'both stay active: a migration must not close a builder\'s live claim on a guess');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('but it DOES heal a genuine collision on the requested name', async () => {
|
|
105
|
+
// Rows written by the new code carry a real requested name, and two of those
|
|
106
|
+
// CAN collide — that is the case the heal exists for, and the case that would
|
|
107
|
+
// otherwise block CREATE UNIQUE INDEX on deploy.
|
|
108
|
+
await pool.query(`TRUNCATE claims`);
|
|
109
|
+
// Drop the index FIRST — the point is to recreate the pre-migration state in
|
|
110
|
+
// which these two rows can coexist, then prove the migration heals them.
|
|
111
|
+
await pool.query('DROP INDEX IF EXISTS uniq_active_claim_per_session_requested_worktree');
|
|
112
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id, claimed_at) VALUES
|
|
113
|
+
(60, 1, 'wt', 'wt', 'sess-H', now() - interval '2 hour'),
|
|
114
|
+
(61, 1, 'wt-2', 'wt', 'sess-H', now() - interval '1 hour')`);
|
|
115
|
+
await pool.query(MIG);
|
|
116
|
+
const { rows } = await pool.query(
|
|
117
|
+
`SELECT task_id, released_at, outcome FROM claims ORDER BY claimed_at`);
|
|
118
|
+
assert.equal(rows[0].released_at, null, 'the earliest survives');
|
|
119
|
+
assert.ok(rows[1].released_at, 'the later is closed');
|
|
120
|
+
assert.equal(rows[1].outcome, 'abandoned');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await t('the backfill copies worktree_name across, and never overwrites a real value', async () => {
|
|
124
|
+
await pool.query(`TRUNCATE claims`);
|
|
125
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id) VALUES
|
|
126
|
+
(70, 1, 'legacy', NULL, 'sess-I'),
|
|
127
|
+
(71, 1, 'newer', 'asked-for','sess-J')`);
|
|
128
|
+
await pool.query(MIG);
|
|
129
|
+
const { rows } = await pool.query(
|
|
130
|
+
`SELECT task_id, worktree_name, requested_worktree_name FROM claims ORDER BY task_id`);
|
|
131
|
+
assert.equal(rows[0].requested_worktree_name, 'legacy', 'a NULL historical row is filled from the stored name');
|
|
132
|
+
assert.equal(rows[1].requested_worktree_name, 'asked-for',
|
|
133
|
+
'a row that already carries a real requested name is left alone — the guard on the UPDATE');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('re-running the migration is idempotent', async () => {
|
|
137
|
+
await pool.query(MIG);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('the old index is gone and the new one exists', async () => {
|
|
141
|
+
const { rows } = await pool.query(
|
|
142
|
+
`SELECT indexname FROM pg_indexes WHERE tablename = 'claims'`);
|
|
143
|
+
const names = rows.map((r) => r.indexname);
|
|
144
|
+
assert.ok(!names.includes('uniq_active_claim_per_session_worktree'),
|
|
145
|
+
'leaving it would re-refuse the legitimate foo / foo-2 case');
|
|
146
|
+
assert.ok(names.includes('uniq_active_claim_per_session_requested_worktree'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await pool.query(`TRUNCATE claims`);
|
|
150
|
+
const ins = (o) => pool.query(
|
|
151
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id)
|
|
152
|
+
VALUES ($1,1,$2,$3,$4) RETURNING id`,
|
|
153
|
+
[o.task, o.stored, o.requested, o.session]);
|
|
154
|
+
|
|
155
|
+
console.log('\nthe index now catches what the guard catches');
|
|
156
|
+
|
|
157
|
+
await t('THE HOLE IS CLOSED: same session, same REQUESTED tree, second claim → 23505', async () => {
|
|
158
|
+
await ins({ task: 10, stored: 'overnight-2', requested: 'overnight', session: 'sess-B' });
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => ins({ task: 11, stored: 'overnight-3', requested: 'overnight', session: 'sess-B' }),
|
|
161
|
+
(e) => e.code === '23505' && e.constraint === 'uniq_active_claim_per_session_requested_worktree');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await t('two genuinely different trees in one session still BOTH insert', async () => {
|
|
165
|
+
await pool.query(`TRUNCATE claims`);
|
|
166
|
+
await ins({ task: 20, stored: 'foo', requested: 'foo', session: 'sess-C' });
|
|
167
|
+
const r = await ins({ task: 21, stored: 'foo-2', requested: 'foo-2', session: 'sess-C' });
|
|
168
|
+
assert.ok(r.rows[0].id, 'the parallel-worktree workflow must keep working');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('two worktree-less claims in one session still self-collide', async () => {
|
|
172
|
+
await pool.query(`TRUNCATE claims`);
|
|
173
|
+
await ins({ task: 30, stored: null, requested: null, session: 'sess-D' });
|
|
174
|
+
await assert.rejects(
|
|
175
|
+
() => ins({ task: 31, stored: null, requested: null, session: 'sess-D' }),
|
|
176
|
+
(e) => e.code === '23505', 'one shared clone, one branch — NULLs must fold via COALESCE');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await t('a released claim frees the name for reuse (the overnight builder)', async () => {
|
|
180
|
+
await pool.query(`TRUNCATE claims`);
|
|
181
|
+
await ins({ task: 40, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
|
|
182
|
+
await pool.query(`UPDATE claims SET released_at = now() WHERE task_id = 40`);
|
|
183
|
+
const r = await ins({ task: 41, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
|
|
184
|
+
assert.ok(r.rows[0].id, 'sequential reuse is load-bearing for the overnight builder');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await t('unbound (NULL-session) claims are still exempt', async () => {
|
|
188
|
+
await pool.query(`TRUNCATE claims`);
|
|
189
|
+
await ins({ task: 50, stored: 'shared', requested: 'shared', session: null });
|
|
190
|
+
const r = await ins({ task: 51, stored: 'shared', requested: 'shared', session: null });
|
|
191
|
+
assert.ok(r.rows[0].id, 'web/unbound claims may coexist — the partial predicate excludes them');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
195
|
+
await pool.end();
|
|
196
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -337,5 +337,130 @@ t('core_204 keeps the 180 predicate (active + bound rows only) and heals first',
|
|
|
337
337
|
'the pre-CREATE heal must group by the NEW key');
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
// =========================================================================
|
|
341
|
+
// task 1002709 — the disambiguation hole. V3.R51 silently RENAMES a colliding
|
|
342
|
+
// worktree_name ("overnight" -> "overnight-2") while the CLI keeps sending the
|
|
343
|
+
// real directory name, which is never renamed. Keying the guard on the STORED
|
|
344
|
+
// name therefore compared two different strings for ONE tree and let a second
|
|
345
|
+
// claim through: one tree, one branch, two active claims — the commingled ship
|
|
346
|
+
// this guard exists to stop. The fix stores the REQUESTED name too and keys on
|
|
347
|
+
// that (core_221).
|
|
348
|
+
// =========================================================================
|
|
349
|
+
|
|
350
|
+
t('THE REPRO: a second claim from the same session into a RENAMED tree is refused', () => {
|
|
351
|
+
// Step 1-2 already happened: this session's first claim asked for "overnight",
|
|
352
|
+
// collided with another builder's active "overnight", and was STORED as
|
|
353
|
+
// "overnight-2". Step 3 is this call — same session, same real directory.
|
|
354
|
+
const held = {
|
|
355
|
+
claim_id: 90, task_id: 800, creator_session_id: 'sess-9',
|
|
356
|
+
worktree_name: 'overnight-2', // what disambiguation stored
|
|
357
|
+
requested_worktree_name: 'overnight', // what the caller actually asked for
|
|
358
|
+
title: 'First',
|
|
359
|
+
};
|
|
360
|
+
const out = shouldRefuseSessionClaim({
|
|
361
|
+
activeClaimsForSession: [held],
|
|
362
|
+
creatorSessionId: 'sess-9',
|
|
363
|
+
worktreeName: 'overnight', // the CLI still sends the real dir
|
|
364
|
+
});
|
|
365
|
+
assert.equal(out.refuse, true,
|
|
366
|
+
'before task 1002709 this ALLOWED — "overnight" vs the stored "overnight-2" read as two trees');
|
|
367
|
+
assert.equal(out.reason, 'session_already_has_active_claim');
|
|
368
|
+
assert.equal(out.inFlightTaskId, 800);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
t('a builder genuinely running "foo" and "foo-2" side by side is still allowed', () => {
|
|
372
|
+
// The false-refusal this whole family of bugs is about. Matching
|
|
373
|
+
// /^<requested>-\d+$/ in the guard would have broken exactly this, which is
|
|
374
|
+
// why the requested name is STORED rather than pattern-matched.
|
|
375
|
+
const held = {
|
|
376
|
+
claim_id: 91, task_id: 801, creator_session_id: 'sess-9',
|
|
377
|
+
worktree_name: 'foo', requested_worktree_name: 'foo', title: 'In foo',
|
|
378
|
+
};
|
|
379
|
+
const out = shouldRefuseSessionClaim({
|
|
380
|
+
activeClaimsForSession: [held],
|
|
381
|
+
creatorSessionId: 'sess-9',
|
|
382
|
+
worktreeName: 'foo-2',
|
|
383
|
+
});
|
|
384
|
+
assert.equal(out.refuse, false, 'two real, separate trees — two branches, no commingling');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
t('rows written before the backfill fall back to worktree_name', () => {
|
|
388
|
+
// A deploy applies code and migration at different instants. In that window an
|
|
389
|
+
// active row has requested_worktree_name NULL, and for any row disambiguation
|
|
390
|
+
// never touched the stored name IS the requested one — so the fallback keeps
|
|
391
|
+
// the guard correct instead of reading every old row as worktree-less.
|
|
392
|
+
const legacy = {
|
|
393
|
+
claim_id: 92, task_id: 802, creator_session_id: 'sess-9',
|
|
394
|
+
worktree_name: 'legacy-tree', title: 'Legacy',
|
|
395
|
+
// requested_worktree_name deliberately absent
|
|
396
|
+
};
|
|
397
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
398
|
+
activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'legacy-tree',
|
|
399
|
+
}).refuse, true, 'same tree by the fallback → refuse');
|
|
400
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
401
|
+
activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'other-tree',
|
|
402
|
+
}).refuse, false, 'different tree → still allowed');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
t('an explicit NULL requested name does NOT silently become the stored name', () => {
|
|
406
|
+
// `??` and not `||`: a row whose requested name is genuinely null (a
|
|
407
|
+
// worktree-less claim, backfilled) must fold to the shared-clone group, and a
|
|
408
|
+
// row whose stored name is null must not resurrect anything either.
|
|
409
|
+
const worktreeless = {
|
|
410
|
+
claim_id: 93, task_id: 803, creator_session_id: 'sess-9',
|
|
411
|
+
worktree_name: null, requested_worktree_name: null, title: 'Shared clone',
|
|
412
|
+
};
|
|
413
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
414
|
+
activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: null,
|
|
415
|
+
}).refuse, true, 'one shared clone, one branch — genuinely commingles');
|
|
416
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
417
|
+
activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: 'wt-real',
|
|
418
|
+
}).refuse, false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const mig221 = () => readFileSync(
|
|
422
|
+
join(dirname(fileURLToPath(import.meta.url)),
|
|
423
|
+
'../migrations/core_221_claims_requested_worktree_name.sql'),
|
|
424
|
+
'utf8');
|
|
425
|
+
|
|
426
|
+
t('core_221 adds the column additively and backfills without overwriting', () => {
|
|
427
|
+
assert.match(mig221(), /ADD COLUMN IF NOT EXISTS requested_worktree_name text/i);
|
|
428
|
+
assert.match(mig221(), /WHERE requested_worktree_name IS NULL/i,
|
|
429
|
+
'the backfill must be guarded so a re-run cannot overwrite a real value');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
t('core_221 re-keys the index onto the REQUESTED name, and drops the old one', () => {
|
|
433
|
+
assert.match(mig221(), /DROP INDEX IF EXISTS uniq_active_claim_per_session_worktree\b/,
|
|
434
|
+
'leaving core_204 index alongside would re-refuse the legitimate foo / foo-2 case');
|
|
435
|
+
assert.match(mig221(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_requested_worktree/);
|
|
436
|
+
assert.match(mig221(), /COALESCE\(requested_worktree_name,\s*''\)/i,
|
|
437
|
+
'NULLs are DISTINCT in a plain unique index — the fold is what makes a shared clone self-collide');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
t('core_221 keeps the partial predicate and heals on the NEW key first', () => {
|
|
441
|
+
assert.match(mig221(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
|
|
442
|
+
'web/unbound claims stay excluded, as in 180 and core_204');
|
|
443
|
+
assert.match(mig221(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.requested_worktree_name,\s*''\)/i,
|
|
444
|
+
'a unique index refuses to create over violating rows — re-keying can newly collide exactly where this bug lived');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
t('all three index generations stay recognised by claimUniqueViolationError', () => {
|
|
448
|
+
// An unrecognised constraint falls closed onto the generic ALREADY_CLAIMED,
|
|
449
|
+
// which for this case is the phantom-rival answer task 1002589 removed.
|
|
450
|
+
for (const name of [
|
|
451
|
+
'uniq_active_claim_per_session',
|
|
452
|
+
'uniq_active_claim_per_session_worktree',
|
|
453
|
+
'uniq_active_claim_per_session_requested_worktree',
|
|
454
|
+
]) {
|
|
455
|
+
assert.equal(
|
|
456
|
+
claimUniqueViolationError({ constraint: name }).code,
|
|
457
|
+
'session_already_has_bound_claim',
|
|
458
|
+
`${name} must render as the honest session answer`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
assert.equal(claimUniqueViolationError({ constraint: 'uniq_active_claim_per_task' }).code,
|
|
462
|
+
'ALREADY_CLAIMED', 'a genuine per-task race still reads as a rival claim');
|
|
463
|
+
});
|
|
464
|
+
|
|
340
465
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
341
466
|
process.exit(failed ? 1 : 0);
|