@bongos/core 1.19.629 → 1.19.630
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 +28 -23
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/multi-builder-merge.md +1 -1
- package/modules/lifecycle/db-claims.js +15 -4
- package/modules/lifecycle/db-ship.js +88 -9
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/routes/claims.js +12 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +4 -20
- package/scripts/gds/cli-lib.js +29 -0
- package/scripts/gds/ship-flow.js +50 -10
- package/scripts/gds/ship-io.js +25 -0
- package/src/module-api.js +1 -1
- package/tests/claim_resolve_worktree_lock.mjs +257 -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.630",
|
|
6
|
+
"core_contract": "1.19.630",
|
|
7
|
+
"source_commit": "8ec5a34a79092057e64af1c4c3762d0b3901efb9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-09T23:03:56.905Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 466,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2099,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2589,
|
|
20
|
+
"tree_sha256": "9937af1178174c35ad4b6fd94cc34d318d04bbc74ec18d9f469b0ba163740e05",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
@@ -397,7 +397,7 @@
|
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.d.ts",
|
|
399
399
|
"mode": "0000644",
|
|
400
|
-
"sha256": "
|
|
400
|
+
"sha256": "c109144665c545407dc0a56a8d08e77b277d7a1636ab3740d0e81e43c563f616"
|
|
401
401
|
},
|
|
402
402
|
{
|
|
403
403
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1887,12 +1887,12 @@
|
|
|
1887
1887
|
{
|
|
1888
1888
|
"path": "docs/api-reference.md",
|
|
1889
1889
|
"mode": "0000644",
|
|
1890
|
-
"sha256": "
|
|
1890
|
+
"sha256": "72bfc066767c1c7301516d4f747c8a29725c2d623b5be1d916e6baa11e20a988"
|
|
1891
1891
|
},
|
|
1892
1892
|
{
|
|
1893
1893
|
"path": "docs/api/openapi.json",
|
|
1894
1894
|
"mode": "0000644",
|
|
1895
|
-
"sha256": "
|
|
1895
|
+
"sha256": "ac3c10aa8cc2cdad0d6878a7b2c51bb668b5207a2cd9649ee87da871e190494e"
|
|
1896
1896
|
},
|
|
1897
1897
|
{
|
|
1898
1898
|
"path": "docs/architecture.md",
|
|
@@ -2767,7 +2767,7 @@
|
|
|
2767
2767
|
{
|
|
2768
2768
|
"path": "docs/module-api-changelog.md",
|
|
2769
2769
|
"mode": "0000644",
|
|
2770
|
-
"sha256": "
|
|
2770
|
+
"sha256": "d70ae49d22a6ae226ad46e3c8d5fbaa0954a8d14697cdb96760f42bc24be7ced"
|
|
2771
2771
|
},
|
|
2772
2772
|
{
|
|
2773
2773
|
"path": "docs/modules-contract.md",
|
|
@@ -2932,7 +2932,7 @@
|
|
|
2932
2932
|
{
|
|
2933
2933
|
"path": "docs/recipes/multi-builder-merge.md",
|
|
2934
2934
|
"mode": "0000644",
|
|
2935
|
-
"sha256": "
|
|
2935
|
+
"sha256": "1770644d6a4ee631d505dbbe4e682f4b6134744746a828a1af503ecc8bb4f759"
|
|
2936
2936
|
},
|
|
2937
2937
|
{
|
|
2938
2938
|
"path": "docs/recipes/ops-gotchas.md",
|
|
@@ -5592,7 +5592,7 @@
|
|
|
5592
5592
|
{
|
|
5593
5593
|
"path": "modules/lifecycle/db-claims.js",
|
|
5594
5594
|
"mode": "0000644",
|
|
5595
|
-
"sha256": "
|
|
5595
|
+
"sha256": "23ed6051b8b9283410f23e10862b6b65361cbf2f95489cd1fc50dfd4584f5c71"
|
|
5596
5596
|
},
|
|
5597
5597
|
{
|
|
5598
5598
|
"path": "modules/lifecycle/db-deps-criteria.js",
|
|
@@ -5627,7 +5627,7 @@
|
|
|
5627
5627
|
{
|
|
5628
5628
|
"path": "modules/lifecycle/db-ship.js",
|
|
5629
5629
|
"mode": "0000644",
|
|
5630
|
-
"sha256": "
|
|
5630
|
+
"sha256": "a7a3f24e8cc01953e09c19c9dcb8f4a5a58bdd31fe8cf0076dd7cbb102491cf2"
|
|
5631
5631
|
},
|
|
5632
5632
|
{
|
|
5633
5633
|
"path": "modules/lifecycle/db-tasks.js",
|
|
@@ -5642,7 +5642,7 @@
|
|
|
5642
5642
|
{
|
|
5643
5643
|
"path": "modules/lifecycle/db.js",
|
|
5644
5644
|
"mode": "0000644",
|
|
5645
|
-
"sha256": "
|
|
5645
|
+
"sha256": "95ff6bc6e9f4ad652dc2aa6bc2c7c520409a2115c714cb4ba867fc80413691e8"
|
|
5646
5646
|
},
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -5797,7 +5797,7 @@
|
|
|
5797
5797
|
{
|
|
5798
5798
|
"path": "modules/lifecycle/routes/claims.js",
|
|
5799
5799
|
"mode": "0000644",
|
|
5800
|
-
"sha256": "
|
|
5800
|
+
"sha256": "8a4f6d5513b97e84c44695b88e42c6e5bdc1875e97202be41caf6c739b2fa42e"
|
|
5801
5801
|
},
|
|
5802
5802
|
{
|
|
5803
5803
|
"path": "modules/lifecycle/routes/closeness.js",
|
|
@@ -7697,12 +7697,12 @@
|
|
|
7697
7697
|
{
|
|
7698
7698
|
"path": "package-lock.json",
|
|
7699
7699
|
"mode": "0000644",
|
|
7700
|
-
"sha256": "
|
|
7700
|
+
"sha256": "8ce2c5dbcf03a0638065b823be4fd0bab26ed8695ba9693bc99b090ef6e0f237"
|
|
7701
7701
|
},
|
|
7702
7702
|
{
|
|
7703
7703
|
"path": "package.json",
|
|
7704
7704
|
"mode": "0000644",
|
|
7705
|
-
"sha256": "
|
|
7705
|
+
"sha256": "94be508ef7535b26470470845f7631d438c0716cba55a09a5de676eb65ca2cad"
|
|
7706
7706
|
},
|
|
7707
7707
|
{
|
|
7708
7708
|
"path": "public-docs/index.html",
|
|
@@ -7947,7 +7947,7 @@
|
|
|
7947
7947
|
{
|
|
7948
7948
|
"path": "scripts/gds/claim.js",
|
|
7949
7949
|
"mode": "0000644",
|
|
7950
|
-
"sha256": "
|
|
7950
|
+
"sha256": "d925dfa3831cb3bd16b2ff1761344bd4bedf7d916613fffd9dc5d9574244f525"
|
|
7951
7951
|
},
|
|
7952
7952
|
{
|
|
7953
7953
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -7972,7 +7972,7 @@
|
|
|
7972
7972
|
{
|
|
7973
7973
|
"path": "scripts/gds/cli-lib.js",
|
|
7974
7974
|
"mode": "0000644",
|
|
7975
|
-
"sha256": "
|
|
7975
|
+
"sha256": "c3f5d95c3e586ea711f4e0cef245da9f8106bf4c803aa4c7b2d0025657562680"
|
|
7976
7976
|
},
|
|
7977
7977
|
{
|
|
7978
7978
|
"path": "scripts/gds/client-baseurl-guard.js",
|
|
@@ -8822,7 +8822,7 @@
|
|
|
8822
8822
|
{
|
|
8823
8823
|
"path": "scripts/gds/ship-flow.js",
|
|
8824
8824
|
"mode": "0000644",
|
|
8825
|
-
"sha256": "
|
|
8825
|
+
"sha256": "95b5fa325ea0407dd6cd3d9ae3a03a0a0f0478a0bfcf76d77cf36b13a7c36bea"
|
|
8826
8826
|
},
|
|
8827
8827
|
{
|
|
8828
8828
|
"path": "scripts/gds/ship-git.js",
|
|
@@ -8847,7 +8847,7 @@
|
|
|
8847
8847
|
{
|
|
8848
8848
|
"path": "scripts/gds/ship-io.js",
|
|
8849
8849
|
"mode": "0000644",
|
|
8850
|
-
"sha256": "
|
|
8850
|
+
"sha256": "9db82555be2472f9bd6bdefdfb90411138e767fb7acd10f0d561152c1754426e"
|
|
8851
8851
|
},
|
|
8852
8852
|
{
|
|
8853
8853
|
"path": "scripts/gds/ship-land.js",
|
|
@@ -9442,7 +9442,7 @@
|
|
|
9442
9442
|
{
|
|
9443
9443
|
"path": "src/module-api.js",
|
|
9444
9444
|
"mode": "0000644",
|
|
9445
|
-
"sha256": "
|
|
9445
|
+
"sha256": "9f6fac692c0299219829477c40982314b29fc7fa1854a3544b6cc104ac97791a"
|
|
9446
9446
|
},
|
|
9447
9447
|
{
|
|
9448
9448
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10004,6 +10004,11 @@
|
|
|
10004
10004
|
"mode": "0000644",
|
|
10005
10005
|
"sha256": "d47a0f20617d3bf80cef4d24c816ffa53a045165cd83494a0c8d4c83b7ad16ef"
|
|
10006
10006
|
},
|
|
10007
|
+
{
|
|
10008
|
+
"path": "tests/claim_resolve_worktree_lock.mjs",
|
|
10009
|
+
"mode": "0000644",
|
|
10010
|
+
"sha256": "6e5ff3acb835fd6219136ee2a5e144841076a82733915cc7111688f707464442"
|
|
10011
|
+
},
|
|
10007
10012
|
{
|
|
10008
10013
|
"path": "tests/claims_bind_session.mjs",
|
|
10009
10014
|
"mode": "0000644",
|
|
@@ -229,7 +229,7 @@ export interface PostClaimsBatchRequest { task_ids: unknown[]; worktree_name?: s
|
|
|
229
229
|
export interface PostClaimsBatchResponse { claims: unknown }
|
|
230
230
|
export interface PostClaimsBatchValidateRequest { task_ids: unknown[] }
|
|
231
231
|
export interface PostClaimsIdReleaseOnBehalfRequest { reason: string }
|
|
232
|
-
export interface PostClaimsIdResolveRequest { outcome: "shipped" | "abandoned" | "partial"; notes_md?: string; value_summary?: string; verification?: Record<string, unknown>; force?: boolean; committed_files?: string[]; sandbox_review?: Record<string, unknown
|
|
232
|
+
export interface PostClaimsIdResolveRequest { outcome: "shipped" | "abandoned" | "partial"; notes_md?: string; value_summary?: string; verification?: Record<string, unknown>; force?: boolean; committed_files?: string[]; sandbox_review?: Record<string, unknown>; resolver_worktree?: string }
|
|
233
233
|
export interface PostClaimsRequest { task_id: StringifiableId; worktree_name?: string; head_sha?: string; bind_session?: boolean; enforce_touches?: boolean }
|
|
234
234
|
export interface PostClaimsResponse { claim: unknown; task: unknown; just_unlocked: unknown; overlap_warning: unknown; reward: unknown }
|
|
235
235
|
export interface PostConnectionsIdAcceptResponse { status: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -19564,6 +19564,10 @@
|
|
|
19564
19564
|
},
|
|
19565
19565
|
"sandbox_review": {
|
|
19566
19566
|
"type": "object"
|
|
19567
|
+
},
|
|
19568
|
+
"resolver_worktree": {
|
|
19569
|
+
"type": "string",
|
|
19570
|
+
"maxLength": 200
|
|
19567
19571
|
}
|
|
19568
19572
|
},
|
|
19569
19573
|
"required": [
|
package/docs/api-reference.md
CHANGED
|
@@ -141,7 +141,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
141
141
|
|---|---|---|---|---|
|
|
142
142
|
| POST | `/api/bongos/claims` | `any-builder` | `task_id`, `worktree_name`, `head_sha`, `bind_session`, `enforce_touches` | own work (claim a ready task); Xenos additionally filtered to newcomer_friendly rows inside db.claimTask (returns rank_too_low_for_task 4… |
|
|
143
143
|
| POST | `/api/bongos/claims/:id/release-on-behalf` | `metic+archon` | `reason` | POST /claims/:id/release-on-behalf — V3.R14 (task 238). |
|
|
144
|
-
| POST | `/api/bongos/claims/:id/resolve` | `any-builder` | `outcome`, `notes_md`, `value_summary`, `verification`, `force`, `committed_files`, `sandbox_review` | resolve own claim (ship/abandon); ownership checked in db.resolveClaim (NOT_YOUR_CLAIM). |
|
|
144
|
+
| POST | `/api/bongos/claims/:id/resolve` | `any-builder` | `outcome`, `notes_md`, `value_summary`, `verification`, `force`, `committed_files`, `sandbox_review`, `resolver_worktree` | resolve own claim (ship/abandon); ownership checked in db.resolveClaim (NOT_YOUR_CLAIM). |
|
|
145
145
|
| POST | `/api/bongos/claims/batch` | `any-builder` | `task_ids`, `worktree_name`, `bind_session` | Atomic multi-claim. |
|
|
146
146
|
| POST | `/api/bongos/claims/batch/release` | `any-builder` | `claim_ids`, `force` | Bulk release (task 1758) — the batch twin of resolve(outcome='abandoned'). |
|
|
147
147
|
| POST | `/api/bongos/claims/batch/validate` | `any-builder` | `task_ids` | Dry-run validate (task 1758) — read-only twin of claimTasksBatch's pre-flight checks. |
|
|
@@ -1707,5 +1707,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1707
1707
|
landed since 1.19.627 with no explicit bump. run 34397967061. (task 1002620)
|
|
1708
1708
|
1.19.629 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1709
1709
|
landed since 1.19.628 with no explicit bump. run 34412159317. (task 1002620)
|
|
1710
|
+
1.19.630 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1711
|
+
landed since 1.19.629 with no explicit bump. run 34415169250. (task 1002620)
|
|
1710
1712
|
---------------------------------------------------------------------------
|
|
1711
1713
|
```
|
|
@@ -24,7 +24,7 @@ This recipe is the audit deliverable for [#269](https://example.com/builders#/ta
|
|
|
24
24
|
|---|---|---|---|
|
|
25
25
|
| **`main` worktree** (`REPO_ROOT`): checkout, `git fetch`, `git merge`, `git push origin main`, post-merge smoke, `~/deploy.sh` | **Yes — the single hot resource.** One checkout, one HEAD. | **`merge-lock` mutex** (`.git/otb-automerge.lock`) — held by `ship.js` auto-merge AND `/merge-mode`. | This is the surface [#269](https://example.com/builders#/task/269) closes end-to-end. |
|
|
26
26
|
| **`claims` table** (claim/release) | Yes | Postgres txn + `uniq_active_claim_per_task` unique index + touches-overlap check (`db.js claimTask`) | Deterministic. `ALREADY_CLAIMED` / `TOUCHES_CONFLICT` are clean refusals, not corruption. |
|
|
27
|
-
|
|
|
27
|
+
| **The claim's working tree** (`worktree_name`, falling back to `creator_session_id`) | Yes | `CLAIM_SESSION_MISMATCH` (migration 013; re-keyed to the tree by task 1003767, following the same fix to the claim-side guard in task 1002570) | Only the WORKING TREE that created a claim may ship/release it — a ship publishes the whole branch a tree owns, so resolving from elsewhere would land the wrong commits. Keyed on the tree, **not** the auth session: one login is shared by every concurrent worktree and is re-issued by `/builder-reauth`, which used to strand the claim outright. Where no tree is named on either side (a plain clone, or an older CLI sending no `resolver_worktree`) the historical session compare still applies. `force` remains the override. |
|
|
28
28
|
| **`worktree_name` uniqueness** | Yes | Server-side disambiguation in `claimTask` (V3.R51 / [#270](https://example.com/builders#/task/270)) | Two claims with the same worktree name → second gets a suffix. |
|
|
29
29
|
| **`migration_counter`** (migration number allocation) | Yes | `UPDATE … RETURNING` inside the claim txn | Atomic; each claim that signals `migrations/` gets a distinct number. |
|
|
30
30
|
| **Feature worktree** (`.claude/worktrees/<name>`) | No — per-builder | n/a | Each builder's feature branch work is isolated. The collision only appears at merge time, on `main`. |
|
|
@@ -20,7 +20,7 @@ const { goalMembershipFailures } = require('./claim-feed.js');
|
|
|
20
20
|
const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
21
21
|
const { claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName, rankAllowsTask, xenosClaimAllowed } = require('./db-rank-authz.js');
|
|
22
22
|
const { sealSessionLog } = require('./db-shared.js');
|
|
23
|
-
const { needsRebaseTasksForBuilder } = require('./db-ship.js');
|
|
23
|
+
const { needsRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
24
24
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
25
25
|
|
|
26
26
|
// -------------------------------------------------------------------------
|
|
@@ -725,7 +725,7 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
725
725
|
// still releases. Mirrors resolveClaim's per-claim effects (released_at +
|
|
726
726
|
// outcome, task → 'ready', sealSessionLog, actual_minutes recompute) and
|
|
727
727
|
// releaseClaimOnBehalf's row-locked batch-read shape.
|
|
728
|
-
async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, force }) {
|
|
728
|
+
async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, resolverWorktree = null, force }) {
|
|
729
729
|
if (!Array.isArray(claimIds) || claimIds.length === 0) {
|
|
730
730
|
throw { code: 'BATCH_EMPTY' };
|
|
731
731
|
}
|
|
@@ -752,8 +752,19 @@ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, forc
|
|
|
752
752
|
if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
|
|
753
753
|
if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
|
|
754
754
|
if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
|
|
755
|
-
|
|
756
|
-
|
|
755
|
+
// task 1003767: the SAME decision resolveClaim uses, not a third copy of
|
|
756
|
+
// the auth-session compare (this was the second of three). Batch release
|
|
757
|
+
// has no CLI caller passing a tree yet, so resolverWorktree is normally
|
|
758
|
+
// null here and the decision falls back to the session compare — today's
|
|
759
|
+
// behaviour exactly. Wired anyway so a re-auth cannot strand a batch
|
|
760
|
+
// release the moment a caller does send one.
|
|
761
|
+
if (shouldRefuseClaimResolve({
|
|
762
|
+
claimSessionId: claim.creator_session_id,
|
|
763
|
+
resolverSessionId,
|
|
764
|
+
claimWorktree: claim.worktree_name,
|
|
765
|
+
resolverWorktree,
|
|
766
|
+
force,
|
|
767
|
+
}).refuse) {
|
|
757
768
|
failures.push({ claim_id: claimId, reason: 'CLAIM_SESSION_MISMATCH', claimWorktree: claim.worktree_name ?? null });
|
|
758
769
|
continue;
|
|
759
770
|
}
|
|
@@ -15,9 +15,75 @@ const { pool, withTx, insertAuditLog } = api;
|
|
|
15
15
|
const log = api.logger('lifecycle');
|
|
16
16
|
const seams = api;
|
|
17
17
|
const doneWhen = require('./done-when.js');
|
|
18
|
-
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes } = require('./db-rank-authz.js');
|
|
18
|
+
const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName } = require('./db-rank-authz.js');
|
|
19
19
|
const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
|
|
20
20
|
|
|
21
|
+
// shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
|
|
22
|
+
//
|
|
23
|
+
// The guard exists to catch a builder shipping the wrong claim from the wrong
|
|
24
|
+
// place: /builder-ship publishes the whole branch a working tree owns, so a
|
|
25
|
+
// resolve arriving from a different tree than the one that did the work lands
|
|
26
|
+
// somebody else's commits. The route has always described it that way ("catches a
|
|
27
|
+
// builder accidentally shipping from the wrong worktree", routes/claims.js).
|
|
28
|
+
//
|
|
29
|
+
// But it was IMPLEMENTED as `creator_session_id !== resolverSessionId`, and that
|
|
30
|
+
// is the AUTH-TOKEN session — one login shared by every concurrent worktree, and
|
|
31
|
+
// re-issued whenever the builder runs /builder-reauth. So re-authing mid-claim
|
|
32
|
+
// made the builder's own claim permanently unresolvable (CLAIM_SESSION_MISMATCH
|
|
33
|
+
// on every ship, with `release --force` — forfeiting graded work — the only exit).
|
|
34
|
+
// A standing "don't run /builder-reauth while holding a claim" warning in the
|
|
35
|
+
// project handoff was the symptom.
|
|
36
|
+
//
|
|
37
|
+
// This is the SAME confusion shouldRefuseSessionClaim (routes/claims.js) was
|
|
38
|
+
// already fixed for; its comment states the rule: "The hazard boundary is the
|
|
39
|
+
// WORKING TREE (one worktree = one branch = one HEAD), NOT the auth session."
|
|
40
|
+
// This is that rule applied to the resolve side.
|
|
41
|
+
//
|
|
42
|
+
// Worktree wins WHERE KNOWN, session is the fallback. When both sides name a
|
|
43
|
+
// tree, that comparison is the whole decision — a re-auth changes the session and
|
|
44
|
+
// is correctly ignored, while a resolve from a genuinely different tree is still
|
|
45
|
+
// refused (the hazard is real, not the guard). When either side does NOT name one
|
|
46
|
+
// — an older CLI that sends no worktree, or a claim made from a plain clone — the
|
|
47
|
+
// tree cannot be compared, so we fall back to the historical session check rather
|
|
48
|
+
// than waving the resolve through. Refusing on the best signal available keeps
|
|
49
|
+
// this a tightening-only change: nothing that was refused before is now allowed
|
|
50
|
+
// except the re-auth case this task exists to fix.
|
|
51
|
+
//
|
|
52
|
+
// Existing skips are unchanged: force=true (the emergency hatch), a claim with no
|
|
53
|
+
// creator_session_id (a web/unbound claim per #616, or one pre-dating the column),
|
|
54
|
+
// and a resolver with no session context at all.
|
|
55
|
+
//
|
|
56
|
+
// Pure + exported so the decision is unit-testable with no DB and no route —
|
|
57
|
+
// the same shape as shouldRefuseSessionClaim. Returns { refuse, reason }.
|
|
58
|
+
function shouldRefuseClaimResolve({
|
|
59
|
+
claimSessionId,
|
|
60
|
+
resolverSessionId,
|
|
61
|
+
claimWorktree = null,
|
|
62
|
+
resolverWorktree = null,
|
|
63
|
+
force = false,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const allow = { refuse: false, reason: null };
|
|
66
|
+
if (force) return allow;
|
|
67
|
+
// Nothing to scope to: an unbound/web claim is adoptable by any of the
|
|
68
|
+
// builder's own sessions, and a session-less resolver predates the check.
|
|
69
|
+
if (!claimSessionId || !resolverSessionId) return allow;
|
|
70
|
+
|
|
71
|
+
// Fold through the SAME normalization every worktree_name ingest point uses,
|
|
72
|
+
// so '' / ' ' / null all mean "no worktree" here exactly as they do in the
|
|
73
|
+
// DB index and in shouldRefuseSessionClaim (task 1002522).
|
|
74
|
+
const claimTree = foldWorktreeName(claimWorktree);
|
|
75
|
+
const resolverTree = foldWorktreeName(resolverWorktree);
|
|
76
|
+
|
|
77
|
+
// Both sides named a working tree → that IS the hazard boundary. Decide on it
|
|
78
|
+
// alone and ignore the session, which is what makes a re-auth survivable.
|
|
79
|
+
if (claimTree && resolverTree) {
|
|
80
|
+
return claimTree === resolverTree ? allow : { refuse: true, reason: 'worktree_mismatch' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Tree unknown on at least one side → fall back to the session compare.
|
|
84
|
+
return claimSessionId === resolverSessionId ? allow : { refuse: true, reason: 'session_mismatch' };
|
|
85
|
+
}
|
|
86
|
+
|
|
21
87
|
// Resolve a claim. Three-state task lifecycle:
|
|
22
88
|
// active → completed (builder declared done; awaiting grader)
|
|
23
89
|
// active → completed → confirmed (promoted by applyGrade when the subagent
|
|
@@ -39,7 +105,7 @@ const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = req
|
|
|
39
105
|
// at resolveClaim time. Both keys stay in the response shape so existing
|
|
40
106
|
// callers (ship.js) don't break.
|
|
41
107
|
|
|
42
|
-
async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary, verification, resolverSessionId, force, committedFiles = null, sandboxReview = null }) {
|
|
108
|
+
async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary, verification, resolverSessionId, resolverWorktree = null, force, committedFiles = null, sandboxReview = null }) {
|
|
43
109
|
return withTx(async (client) => {
|
|
44
110
|
const { rows: claimRows } = await client.query(
|
|
45
111
|
`SELECT c.id, c.task_id, c.builder_id, c.released_at, c.claimed_at,
|
|
@@ -53,13 +119,25 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
|
|
|
53
119
|
const claim = claimRows[0];
|
|
54
120
|
if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
|
|
55
121
|
if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
// session
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
// Resolve lock: only the WORKING TREE that created this claim can resolve it
|
|
123
|
+
// (task 1003767) — falling back to the creating session where no tree is
|
|
124
|
+
// named. See shouldRefuseClaimResolve above for why the tree, not the auth
|
|
125
|
+
// session, is the hazard boundary. Skips: force=true, an unbound/web claim,
|
|
126
|
+
// or a resolver with no session context. `reason` distinguishes the two
|
|
127
|
+
// refusal shapes so the CLI can say which one happened.
|
|
128
|
+
const resolveLock = shouldRefuseClaimResolve({
|
|
129
|
+
claimSessionId: claim.creator_session_id,
|
|
130
|
+
resolverSessionId,
|
|
131
|
+
claimWorktree: claim.worktree_name,
|
|
132
|
+
resolverWorktree,
|
|
133
|
+
force,
|
|
134
|
+
});
|
|
135
|
+
if (resolveLock.refuse) {
|
|
136
|
+
throw {
|
|
137
|
+
code: 'CLAIM_SESSION_MISMATCH',
|
|
138
|
+
reason: resolveLock.reason,
|
|
139
|
+
claimWorktree: claim.worktree_name ?? null,
|
|
140
|
+
};
|
|
63
141
|
}
|
|
64
142
|
|
|
65
143
|
await client.query(
|
|
@@ -1149,4 +1227,5 @@ module.exports = {
|
|
|
1149
1227
|
restoreTask,
|
|
1150
1228
|
setClaimPublishedSha,
|
|
1151
1229
|
shipTask,
|
|
1230
|
+
shouldRefuseClaimResolve,
|
|
1152
1231
|
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -187,6 +187,7 @@ const {
|
|
|
187
187
|
restoreTask,
|
|
188
188
|
setClaimPublishedSha,
|
|
189
189
|
shipTask,
|
|
190
|
+
shouldRefuseClaimResolve,
|
|
190
191
|
} = require('./db-ship.js');
|
|
191
192
|
const {
|
|
192
193
|
createOverrideRequest,
|
|
@@ -338,6 +339,7 @@ module.exports = {
|
|
|
338
339
|
releaseClaimsBatch,
|
|
339
340
|
validateClaimBatch,
|
|
340
341
|
resolveClaim,
|
|
342
|
+
shouldRefuseClaimResolve, // task 1003767 — the pure "may this tree resolve this claim?" decision
|
|
341
343
|
confirmTask,
|
|
342
344
|
abandonTask,
|
|
343
345
|
restoreTask, // ADR 0152 — the abandoned→backlog undo that pairs with the Metic+ abandon grant
|
|
@@ -525,6 +525,11 @@ module.exports = function buildClaimsRouter() {
|
|
|
525
525
|
// record { stamp, url, method } when a staged game change was reviewed +
|
|
526
526
|
// approved. The server stamps `by`/`at` authoritatively in db.resolveClaim.
|
|
527
527
|
sandbox_review: { type: 'object' },
|
|
528
|
+
// task 1003767: the working tree this resolve is coming FROM, so the lock
|
|
529
|
+
// can key on the tree instead of the auth session (which a /builder-reauth
|
|
530
|
+
// re-issues, previously stranding the claim). Optional — an older CLI omits
|
|
531
|
+
// it and db.shouldRefuseClaimResolve falls back to the session compare.
|
|
532
|
+
resolver_worktree: { type: 'string', maxLength: LIMITS.WORKTREE_NAME },
|
|
528
533
|
})) return;
|
|
529
534
|
if (!Number.isFinite(Number(req.params.id))) {
|
|
530
535
|
return res.fail('validation_failed', 400, [{ field: 'id', reason: 'wrong_type', expected: 'integer' }]);
|
|
@@ -538,6 +543,7 @@ module.exports = function buildClaimsRouter() {
|
|
|
538
543
|
force,
|
|
539
544
|
committed_files: committedFiles,
|
|
540
545
|
sandbox_review: sandboxReview, // { stamp, url, method } — server stamps by/at
|
|
546
|
+
resolver_worktree: resolverWorktree, // task 1003767 — the tree this resolve comes from
|
|
541
547
|
} = req.body || {};
|
|
542
548
|
try {
|
|
543
549
|
const result = await db.resolveClaim({
|
|
@@ -548,6 +554,12 @@ module.exports = function buildClaimsRouter() {
|
|
|
548
554
|
valueSummary: valueSummary ?? null,
|
|
549
555
|
verification: verification ?? null,
|
|
550
556
|
resolverSessionId: req.gdsSession?.session_id ?? null,
|
|
557
|
+
// task 1003767: the CLI's own working tree. Client-supplied, and used
|
|
558
|
+
// ONLY to compare against the claim's stored worktree_name — it grants
|
|
559
|
+
// nothing on its own. A forged value can at most let the builder resolve
|
|
560
|
+
// THEIR OWN claim (ownership is checked separately, NOT_YOUR_CLAIM),
|
|
561
|
+
// which `release --force` already lets them do anyway.
|
|
562
|
+
resolverWorktree: typeof resolverWorktree === 'string' ? resolverWorktree : null,
|
|
551
563
|
force: force === true,
|
|
552
564
|
committedFiles: Array.isArray(committedFiles) ? committedFiles : null,
|
|
553
565
|
sandboxReview: sandboxReview && typeof sandboxReview === 'object' ? sandboxReview : null,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.630",
|
|
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.630",
|
|
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.630",
|
|
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/scripts/gds/claim.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
const { execSync } = require('node:child_process');
|
|
11
11
|
const fs = require('node:fs');
|
|
12
12
|
const path = require('node:path');
|
|
13
|
-
const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote } = require('./cli-lib');
|
|
13
|
+
const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote, detectWorktreeName } = require('./cli-lib');
|
|
14
14
|
const { nextNumbers, padAdr, padMig } = require('./next-number');
|
|
15
15
|
const { CORE_NS } = require('./migration-namespace');
|
|
16
16
|
const { contextPack, formatContextPack, resolveWorktreeRoot } = require('./context-pack');
|
|
@@ -151,25 +151,9 @@ function captureHeadSha() {
|
|
|
151
151
|
|
|
152
152
|
// Auto-detect this claim's worktree name when --worktree is omitted, so the
|
|
153
153
|
// server's one-active-claim guard (keyed on the WORKING TREE, not the shared auth
|
|
154
|
-
// session) can tell genuinely-parallel worktrees apart.
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// already uses). The MAIN checkout returns null (unchanged behaviour: one claim at
|
|
158
|
-
// a time there — parallelism is what worktrees are for). Best-effort: any git
|
|
159
|
-
// hiccup yields null, so this can never break a claim.
|
|
160
|
-
function detectWorktreeName() {
|
|
161
|
-
try {
|
|
162
|
-
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
163
|
-
const commonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim();
|
|
164
|
-
// Same dir → the main checkout, not a linked worktree → leave unnamed.
|
|
165
|
-
if (path.resolve(gitDir) === path.resolve(commonDir)) return null;
|
|
166
|
-
const top = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
167
|
-
const name = path.basename(top);
|
|
168
|
-
return name || null;
|
|
169
|
-
} catch (_) {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
154
|
+
// session) can tell genuinely-parallel worktrees apart. The detector moved to
|
|
155
|
+
// cli-lib.js in task 1003767 — the ship path now needs the SAME answer to satisfy
|
|
156
|
+
// the resolve lock, and two copies drifting apart would strand a ship.
|
|
173
157
|
|
|
174
158
|
// task 1278: nudge (don't block) when this session's build scripts are behind
|
|
175
159
|
// origin/main. The completion cards + skills are CLI-script features that ride
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -14,6 +14,7 @@ const fs = require('node:fs');
|
|
|
14
14
|
const fsp = require('node:fs/promises');
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const os = require('node:os');
|
|
17
|
+
const { execSync } = require('node:child_process');
|
|
17
18
|
const ic = require('../../src/instance-config');
|
|
18
19
|
|
|
19
20
|
// Session token path — the WRITE target is the configured instance dir
|
|
@@ -1101,8 +1102,36 @@ function repairMsysMangledPath(raw, { exepath = process.env.EXEPATH, platform =
|
|
|
1101
1102
|
return rawFwd.slice(best.length);
|
|
1102
1103
|
}
|
|
1103
1104
|
|
|
1105
|
+
// detectWorktreeName — which linked git worktree is this process running in?
|
|
1106
|
+
// Moved here from claim.js in task 1003767 so the CLAIM and the RESOLVE agree on
|
|
1107
|
+
// one answer: the server keys both the one-active-claim guard and (now) the
|
|
1108
|
+
// resolve lock on the WORKING TREE rather than the shared auth session, and two
|
|
1109
|
+
// copies of this detector drifting apart would strand a ship with a name
|
|
1110
|
+
// mismatch. One definition, both callers.
|
|
1111
|
+
//
|
|
1112
|
+
// Only names a LINKED worktree — git-dir != git-common-dir, i.e. a real
|
|
1113
|
+
// `.claude/worktrees/<name>` checkout — using its top-level directory basename
|
|
1114
|
+
// (the `<name>` the convention already uses). The MAIN checkout returns null,
|
|
1115
|
+
// which the server reads as "no tree named" and falls back to the session
|
|
1116
|
+
// compare. Best-effort: any git hiccup yields null, so this can never break a
|
|
1117
|
+
// claim or a ship.
|
|
1118
|
+
function detectWorktreeName() {
|
|
1119
|
+
try {
|
|
1120
|
+
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim();
|
|
1121
|
+
const commonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim();
|
|
1122
|
+
// Same dir → the main checkout, not a linked worktree → leave unnamed.
|
|
1123
|
+
if (path.resolve(gitDir) === path.resolve(commonDir)) return null;
|
|
1124
|
+
const top = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
|
|
1125
|
+
const name = path.basename(top);
|
|
1126
|
+
return name || null;
|
|
1127
|
+
} catch (_) {
|
|
1128
|
+
return null;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1104
1132
|
module.exports = {
|
|
1105
1133
|
API_BASE,
|
|
1134
|
+
detectWorktreeName,
|
|
1106
1135
|
hallBase,
|
|
1107
1136
|
productName,
|
|
1108
1137
|
rewardPolicy,
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
const { cliClient, requireSession, arg } = require('./cli-lib');
|
|
11
|
+
const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
|
|
12
12
|
const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
|
|
13
13
|
const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
|
|
14
14
|
const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
|
|
@@ -21,7 +21,7 @@ const { gitOk } = require('./ship-git.js');
|
|
|
21
21
|
const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
|
|
22
22
|
const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
|
|
23
23
|
const { buildPriorRoundContext, computeGradeResult, gradeAndRecord, printCreditsAndAchievements, printGradeOutcome } = require('./ship-grade-steps.js');
|
|
24
|
-
const { apiErrorLine, normalizeApiError } = require('./ship-io.js');
|
|
24
|
+
const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./ship-io.js');
|
|
25
25
|
// task 1003205's carve left pushVia() used at regradeMain but never imported, so
|
|
26
26
|
// EVERY resume of a 'completed' task died with ReferenceError before resolving.
|
|
27
27
|
const { pushVia } = require('./ship-deploy-target.js');
|
|
@@ -423,9 +423,7 @@ async function shipMain() {
|
|
|
423
423
|
// It now lands the task at `completed` when verification (smoke) passes, and
|
|
424
424
|
// stops. The v2 subagent grader (step 2 below) is what gates the credits +
|
|
425
425
|
// the completed→confirmed transition.
|
|
426
|
-
const
|
|
427
|
-
id: ac.claim_id,
|
|
428
|
-
body: {
|
|
426
|
+
const resolveBody = {
|
|
429
427
|
outcome: 'shipped',
|
|
430
428
|
notes_md: notes,
|
|
431
429
|
value_summary: summary,
|
|
@@ -445,15 +443,57 @@ async function shipMain() {
|
|
|
445
443
|
// reviewed + approved a staged game change. The server stamps by/at and
|
|
446
444
|
// persists it to tasks.sandbox_review. null = nothing to record.
|
|
447
445
|
sandbox_review: sandboxReview,
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
// task 1003767: the working tree this ship is running in, so the server's
|
|
447
|
+
// resolve lock can key on the TREE instead of the auth session. Without it
|
|
448
|
+
// a /builder-reauth mid-claim re-issued the session and stranded the ship
|
|
449
|
+
// with `release --force` (forfeiting graded work) the only way out. Same
|
|
450
|
+
// detector claim.js uses, so the two names always agree.
|
|
451
|
+
resolver_worktree: detectWorktreeName(),
|
|
452
|
+
};
|
|
453
|
+
let r = await (await cliClient()).claims.postClaimsIdResolve({ id: ac.claim_id, body: resolveBody });
|
|
454
|
+
|
|
455
|
+
// FORWARD COMPATIBILITY (task 1003767). The resolve route validates STRICTLY:
|
|
456
|
+
// an unknown body field is a hard 400, not an ignored extra. @cloudbongos/cli
|
|
457
|
+
// is published separately from the core and instances pin their own core
|
|
458
|
+
// version, so a CLI carrying a field the server has not learned yet would
|
|
459
|
+
// otherwise be unable to ship AT ALL against that instance — the diff that
|
|
460
|
+
// added `resolver_worktree` hit exactly this on its own ship.
|
|
461
|
+
//
|
|
462
|
+
// So: if the server rejects specifically because it does not know
|
|
463
|
+
// `resolver_worktree`, drop it and retry ONCE. The lock then falls back to
|
|
464
|
+
// the session compare server-side, which is what an older core did anyway —
|
|
465
|
+
// no behaviour is lost that that server ever had.
|
|
466
|
+
//
|
|
467
|
+
// Deliberately narrow: it retries only when the details name THIS field as
|
|
468
|
+
// unknown. Any other validation failure — including an unknown field we did
|
|
469
|
+
// not send on purpose — still surfaces, so this cannot mask a real bug.
|
|
470
|
+
if (!r.ok && isUnknownFieldRejection(r.status, r.data, 'resolver_worktree')) {
|
|
471
|
+
console.error("note: this instance's core predates resolver_worktree — retrying without it.");
|
|
472
|
+
console.error(' (the resolve lock falls back to the session check on that server.)');
|
|
473
|
+
delete resolveBody.resolver_worktree;
|
|
474
|
+
r = await (await cliClient()).claims.postClaimsIdResolve({ id: ac.claim_id, body: resolveBody });
|
|
475
|
+
}
|
|
450
476
|
if (!r.ok) {
|
|
451
477
|
const resolveErr = normalizeApiError(r.data);
|
|
452
478
|
if (resolveErr.code === 'CLAIM_SESSION_MISMATCH') {
|
|
453
479
|
const wt = resolveErr.details.claimWorktree;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
480
|
+
// task 1003767: name WHICH lock fired. 'worktree_mismatch' means the claim
|
|
481
|
+
// really was made in another working tree — the branch this ship would
|
|
482
|
+
// publish is not the branch that did the work, so shipping here would land
|
|
483
|
+
// the wrong commits. That is the guard doing its job, and the fix is to run
|
|
484
|
+
// the ship from the right tree, NOT to force-release. Only a fallback
|
|
485
|
+
// 'session_mismatch' (no tree named on one side — a plain clone, or an
|
|
486
|
+
// older CLI) is the historical session case.
|
|
487
|
+
if (resolveErr.details.reason === 'worktree_mismatch') {
|
|
488
|
+
console.error(`This claim belongs to a different working tree${wt ? ` (worktree: ${wt})` : ''}.`);
|
|
489
|
+
console.error(`Ship it from there — its branch holds the work: cd .claude/worktrees/${wt || '<name>'}`);
|
|
490
|
+
console.error('Shipping from this tree would publish the wrong branch, which is why it is refused.');
|
|
491
|
+
} else {
|
|
492
|
+
console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
|
|
493
|
+
console.error('No working tree was named on one side, so the older session check applied.');
|
|
494
|
+
}
|
|
495
|
+
console.error(`If the work is genuinely gone, give the task back: node scripts/gds/release.js ${taskId} --force`);
|
|
496
|
+
console.error('(--force abandons the claim and forfeits its credits — it is the give-up exit, not the fix.)');
|
|
457
497
|
} else {
|
|
458
498
|
console.error(`resolve failed: ${apiErrorLine(r.data, r.status)}`);
|
|
459
499
|
}
|
package/scripts/gds/ship-io.js
CHANGED
|
@@ -57,6 +57,30 @@ function normalizeApiError(data) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// isUnknownFieldRejection — did the server reject this request ONLY because it
|
|
61
|
+
// does not know the named body field? (task 1003767)
|
|
62
|
+
//
|
|
63
|
+
// WHY THIS EXISTS. The resolve route validates STRICTLY: an unknown body field
|
|
64
|
+
// is a hard 400, not an ignored extra. @cloudbongos/cli ships separately from
|
|
65
|
+
// the core and instances pin their own core version, so a CLI that starts
|
|
66
|
+
// sending a NEW field cannot resolve a claim at all against an instance whose
|
|
67
|
+
// server has not learned it yet — the change that added `resolver_worktree` hit
|
|
68
|
+
// exactly that on its own ship. A caller uses this to drop the field and retry
|
|
69
|
+
// once, degrading to the older server's behaviour instead of failing to ship.
|
|
70
|
+
//
|
|
71
|
+
// Deliberately NARROW: true only when every returned detail names THIS field.
|
|
72
|
+
// If the server also complained about anything else, the request has a real
|
|
73
|
+
// problem and must surface — a blanket "retry without extras" would turn every
|
|
74
|
+
// validation bug into a silent second attempt.
|
|
75
|
+
//
|
|
76
|
+
// Pure + exported so the decision is unit-testable without a server.
|
|
77
|
+
function isUnknownFieldRejection(status, data, field) {
|
|
78
|
+
if (status !== 400) return false;
|
|
79
|
+
const details = normalizeApiError(data).details;
|
|
80
|
+
if (!Array.isArray(details) || details.length === 0) return false;
|
|
81
|
+
return details.every((d) => d && d.field === field);
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
// task 1002789: the ONE renderer for a failed API call on the ship path.
|
|
61
85
|
// Every generic bail site used to hand the raw body to console.error (or
|
|
62
86
|
// interpolate `data.error` into a template, which stringifies the ADR 0116
|
|
@@ -109,6 +133,7 @@ module.exports = {
|
|
|
109
133
|
shipPrefsPath,
|
|
110
134
|
apiErrorLine,
|
|
111
135
|
normalizeApiError,
|
|
136
|
+
isUnknownFieldRejection,
|
|
112
137
|
playShipBell,
|
|
113
138
|
postLandShipOutcome,
|
|
114
139
|
};
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.630'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// tests/claim_resolve_worktree_lock.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003767 — the resolve lock keys on the WORKING TREE, not the auth session.
|
|
4
|
+
//
|
|
5
|
+
// The guard exists to stop a builder shipping a claim from the wrong place:
|
|
6
|
+
// /builder-ship publishes the whole branch a working tree owns, so a resolve
|
|
7
|
+
// arriving from a different tree lands the wrong commits. It was IMPLEMENTED as
|
|
8
|
+
// `creator_session_id !== resolverSessionId` — the AUTH-TOKEN session, which one
|
|
9
|
+
// login shares across every concurrent worktree and which /builder-reauth
|
|
10
|
+
// RE-ISSUES. So re-authing mid-claim made the builder's own claim permanently
|
|
11
|
+
// unresolvable (CLAIM_SESSION_MISMATCH on every ship, `release --force` the only
|
|
12
|
+
// exit, graded work forfeited). This is the same auth-session-vs-worktree
|
|
13
|
+
// confusion shouldRefuseSessionClaim was already fixed for (task 1002570).
|
|
14
|
+
//
|
|
15
|
+
// What these cases defend, in order of how badly it would hurt to lose it:
|
|
16
|
+
//
|
|
17
|
+
// 1. THE RE-AUTH CASE. Same tree, changed session → ALLOW. This is the bug.
|
|
18
|
+
// 2. THE HAZARD IS STILL GUARDED. Different tree → REFUSE, even on one session.
|
|
19
|
+
// A fix that just deleted the check would pass (1) and silently permit the
|
|
20
|
+
// commingled ship the guard exists for. Both halves are asserted.
|
|
21
|
+
// 3. TIGHTENING-ONLY. Where no tree is named (older CLI, plain clone), the
|
|
22
|
+
// historical session compare still applies — nothing previously refused is
|
|
23
|
+
// now allowed except case (1).
|
|
24
|
+
// 4. THE EXISTING SKIPS survive: force, an unbound/web claim, no resolver
|
|
25
|
+
// session.
|
|
26
|
+
//
|
|
27
|
+
// DB-free, network-free — the decision is pure, the same shape as
|
|
28
|
+
// shouldRefuseSessionClaim.
|
|
29
|
+
// Run: node --test tests/claim_resolve_worktree_lock.mjs
|
|
30
|
+
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import { test } from 'node:test';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
|
|
35
|
+
const require = createRequire(import.meta.url);
|
|
36
|
+
const { shouldRefuseClaimResolve } = require('../modules/lifecycle/db-ship.js');
|
|
37
|
+
|
|
38
|
+
// ── 1) the bug this task exists to fix ──────────────────────────────────────────
|
|
39
|
+
test('re-auth mid-claim: same worktree, NEW session → allowed', () => {
|
|
40
|
+
const d = shouldRefuseClaimResolve({
|
|
41
|
+
claimSessionId: 'sess-before-reauth',
|
|
42
|
+
resolverSessionId: 'sess-AFTER-reauth',
|
|
43
|
+
claimWorktree: 'task-1003767',
|
|
44
|
+
resolverWorktree: 'task-1003767',
|
|
45
|
+
});
|
|
46
|
+
assert.equal(d.refuse, false, '/builder-reauth must not strand the claim it was run beside');
|
|
47
|
+
assert.equal(d.reason, null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('same worktree, same session → allowed (the ordinary happy path)', () => {
|
|
51
|
+
const d = shouldRefuseClaimResolve({
|
|
52
|
+
claimSessionId: 'sess-1',
|
|
53
|
+
resolverSessionId: 'sess-1',
|
|
54
|
+
claimWorktree: 'task-42',
|
|
55
|
+
resolverWorktree: 'task-42',
|
|
56
|
+
});
|
|
57
|
+
assert.equal(d.refuse, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── 2) the hazard the guard exists for is STILL refused ─────────────────────────
|
|
61
|
+
test('different worktree → refused, even when the session matches', () => {
|
|
62
|
+
const d = shouldRefuseClaimResolve({
|
|
63
|
+
claimSessionId: 'sess-1',
|
|
64
|
+
resolverSessionId: 'sess-1',
|
|
65
|
+
claimWorktree: 'task-100',
|
|
66
|
+
resolverWorktree: 'task-999',
|
|
67
|
+
});
|
|
68
|
+
assert.equal(d.refuse, true, 'shipping from another tree would publish the wrong branch');
|
|
69
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('different worktree AND different session → refused as a worktree mismatch', () => {
|
|
73
|
+
// Both signals disagree; the tree is the one that names the real hazard, so the
|
|
74
|
+
// reason must be the worktree one (the CLI branches its advice on it).
|
|
75
|
+
const d = shouldRefuseClaimResolve({
|
|
76
|
+
claimSessionId: 'sess-1',
|
|
77
|
+
resolverSessionId: 'sess-2',
|
|
78
|
+
claimWorktree: 'task-100',
|
|
79
|
+
resolverWorktree: 'task-999',
|
|
80
|
+
});
|
|
81
|
+
assert.equal(d.refuse, true);
|
|
82
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── 3) tightening-only: the session fallback where no tree is named ──────────────
|
|
86
|
+
test('no tree on either side, different sessions → still refused (historical check)', () => {
|
|
87
|
+
const d = shouldRefuseClaimResolve({
|
|
88
|
+
claimSessionId: 'sess-1',
|
|
89
|
+
resolverSessionId: 'sess-2',
|
|
90
|
+
claimWorktree: null,
|
|
91
|
+
resolverWorktree: null,
|
|
92
|
+
});
|
|
93
|
+
assert.equal(d.refuse, true, 'a plain clone keeps the old session guard — this is not a loosening');
|
|
94
|
+
assert.equal(d.reason, 'session_mismatch');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('no tree on either side, same session → allowed', () => {
|
|
98
|
+
const d = shouldRefuseClaimResolve({
|
|
99
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: null, resolverWorktree: null,
|
|
100
|
+
});
|
|
101
|
+
assert.equal(d.refuse, false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('claim named a tree but the resolver did not (older CLI) → session decides', () => {
|
|
105
|
+
// An older /builder-ship sends no resolver_worktree. It must not be able to
|
|
106
|
+
// resolve another session's claim just by omitting the field.
|
|
107
|
+
const refused = shouldRefuseClaimResolve({
|
|
108
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: 'task-7', resolverWorktree: null,
|
|
109
|
+
});
|
|
110
|
+
assert.equal(refused.refuse, true, 'omitting the tree must not be a way around the lock');
|
|
111
|
+
assert.equal(refused.reason, 'session_mismatch');
|
|
112
|
+
|
|
113
|
+
const allowed = shouldRefuseClaimResolve({
|
|
114
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: 'task-7', resolverWorktree: null,
|
|
115
|
+
});
|
|
116
|
+
assert.equal(allowed.refuse, false, 'an older CLI on its own session still ships fine');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('resolver named a tree but the claim did not → session decides', () => {
|
|
120
|
+
const d = shouldRefuseClaimResolve({
|
|
121
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: null, resolverWorktree: 'task-7',
|
|
122
|
+
});
|
|
123
|
+
assert.equal(d.refuse, true);
|
|
124
|
+
assert.equal(d.reason, 'session_mismatch');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── 4) the pre-existing skips are unchanged ─────────────────────────────────────
|
|
128
|
+
test('force=true skips the lock entirely (the emergency hatch)', () => {
|
|
129
|
+
const d = shouldRefuseClaimResolve({
|
|
130
|
+
claimSessionId: 'sess-1',
|
|
131
|
+
resolverSessionId: 'sess-2',
|
|
132
|
+
claimWorktree: 'task-100',
|
|
133
|
+
resolverWorktree: 'task-999',
|
|
134
|
+
force: true,
|
|
135
|
+
});
|
|
136
|
+
assert.equal(d.refuse, false, 'force must still override even a genuine worktree mismatch');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('an unbound/web claim (no creator session) is adoptable by any own session', () => {
|
|
140
|
+
// #616: a claim made in the browser has no working session. Binding it would
|
|
141
|
+
// force every such builder to release --force before they could ship.
|
|
142
|
+
const d = shouldRefuseClaimResolve({
|
|
143
|
+
claimSessionId: null, resolverSessionId: 'sess-cli', claimWorktree: null, resolverWorktree: 'task-7',
|
|
144
|
+
});
|
|
145
|
+
assert.equal(d.refuse, false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a resolver with no session context is allowed (legacy path)', () => {
|
|
149
|
+
const d = shouldRefuseClaimResolve({
|
|
150
|
+
claimSessionId: 'sess-1', resolverSessionId: null, claimWorktree: 'task-7', resolverWorktree: 'task-9',
|
|
151
|
+
});
|
|
152
|
+
assert.equal(d.refuse, false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── 5) normalization agrees with every other worktree_name ingest point ─────────
|
|
156
|
+
// foldWorktreeName (task 1002522) is the ONE normalization: '' and whitespace-only
|
|
157
|
+
// both mean "no worktree", matching the DB index's COALESCE(worktree_name, '').
|
|
158
|
+
// If this decision folded differently it could allow a resolve the index-backed
|
|
159
|
+
// claim guard considers a different tree.
|
|
160
|
+
test("'' and whitespace-only fold to 'no tree named', not to a tree called ''", () => {
|
|
161
|
+
const blank = shouldRefuseClaimResolve({
|
|
162
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-2', claimWorktree: '', resolverWorktree: '',
|
|
163
|
+
});
|
|
164
|
+
assert.equal(blank.refuse, true, "two ''-named sides are worktree-LESS, so the session compare applies");
|
|
165
|
+
assert.equal(blank.reason, 'session_mismatch');
|
|
166
|
+
|
|
167
|
+
const spaces = shouldRefuseClaimResolve({
|
|
168
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: ' ', resolverWorktree: 'task-7',
|
|
169
|
+
});
|
|
170
|
+
assert.equal(spaces.refuse, false, 'whitespace-only is not a tree name; same session still ships');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('surrounding whitespace is trimmed, not treated as a different tree', () => {
|
|
174
|
+
const d = shouldRefuseClaimResolve({
|
|
175
|
+
claimSessionId: 'sess-A',
|
|
176
|
+
resolverSessionId: 'sess-B',
|
|
177
|
+
claimWorktree: 'task-1003767',
|
|
178
|
+
resolverWorktree: ' task-1003767 ',
|
|
179
|
+
});
|
|
180
|
+
assert.equal(d.refuse, false, 'an untrimmed name must not strand a ship on a phantom mismatch');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('tree comparison is exact, not a prefix match', () => {
|
|
184
|
+
const d = shouldRefuseClaimResolve({
|
|
185
|
+
claimSessionId: 'sess-1', resolverSessionId: 'sess-1', claimWorktree: 'task-100', resolverWorktree: 'task-1000',
|
|
186
|
+
});
|
|
187
|
+
assert.equal(d.refuse, true, 'task-1000 is a different tree from task-100');
|
|
188
|
+
assert.equal(d.reason, 'worktree_mismatch');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ── 6) degrades safely on odd input (never throws) ──────────────────────────────
|
|
192
|
+
test('no arguments at all does not throw and allows (nothing to scope to)', () => {
|
|
193
|
+
const d = shouldRefuseClaimResolve();
|
|
194
|
+
assert.equal(d.refuse, false);
|
|
195
|
+
assert.equal(d.reason, null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ── 7) forward compatibility: a new CLI must still ship against an OLDER core ───
|
|
199
|
+
// The resolve route validates STRICTLY — an unknown body field is a hard 400, not
|
|
200
|
+
// an ignored extra (routes/_helpers.js validate(), strict defaults on). The core
|
|
201
|
+
// ships separately from @cloudbongos/cli and instances pin their own version, so
|
|
202
|
+
// a CLI that starts sending resolver_worktree cannot resolve AT ALL against a
|
|
203
|
+
// server that has not learned it. This was not theoretical: the very ship of this
|
|
204
|
+
// change was refused `validation_failed [HTTP 400]` by prod, which runs an older
|
|
205
|
+
// core. The CLI drops the field and retries once; these pin that decision.
|
|
206
|
+
const { isUnknownFieldRejection } = require('../scripts/gds/ship-io.js');
|
|
207
|
+
|
|
208
|
+
// The EXACT envelope prod returned on the refused ship (ADR 0116 shape, with
|
|
209
|
+
// reason 'unknown_field' from validate()'s strict branch).
|
|
210
|
+
const oldCoreRejection = {
|
|
211
|
+
error: {
|
|
212
|
+
code: 'validation_failed',
|
|
213
|
+
message: 'validation_failed',
|
|
214
|
+
details: [{ field: 'resolver_worktree', reason: 'unknown_field' }],
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
test('an older core rejecting only resolver_worktree triggers the retry', () => {
|
|
219
|
+
assert.equal(isUnknownFieldRejection(400, oldCoreRejection, 'resolver_worktree'), true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a rejection naming OTHER fields too must NOT be retried away', () => {
|
|
223
|
+
// A real validation bug alongside ours has to surface, not be masked by a
|
|
224
|
+
// silent second attempt that drops one field and fails again for the rest.
|
|
225
|
+
const mixed = {
|
|
226
|
+
error: {
|
|
227
|
+
code: 'validation_failed',
|
|
228
|
+
details: [
|
|
229
|
+
{ field: 'resolver_worktree', reason: 'unknown_field' },
|
|
230
|
+
{ field: 'outcome', reason: 'missing' },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
assert.equal(isUnknownFieldRejection(400, mixed, 'resolver_worktree'), false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a rejection about a DIFFERENT field alone is not our case', () => {
|
|
238
|
+
const other = {
|
|
239
|
+
error: { code: 'validation_failed', details: [{ field: 'notes_md', reason: 'too_long' }] },
|
|
240
|
+
};
|
|
241
|
+
assert.equal(isUnknownFieldRejection(400, other, 'resolver_worktree'), false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('only a 400 counts — a 403/409/500 is never an unknown-field rejection', () => {
|
|
245
|
+
for (const status of [403, 409, 500, 200]) {
|
|
246
|
+
assert.equal(isUnknownFieldRejection(status, oldCoreRejection, 'resolver_worktree'), false,
|
|
247
|
+
`status ${status} must not trigger the retry`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('an empty or malformed details payload does not trigger the retry', () => {
|
|
252
|
+
assert.equal(isUnknownFieldRejection(400, { error: { code: 'validation_failed', details: [] } }, 'resolver_worktree'), false,
|
|
253
|
+
'no details means we cannot attribute the failure to our field');
|
|
254
|
+
assert.equal(isUnknownFieldRejection(400, {}, 'resolver_worktree'), false);
|
|
255
|
+
assert.equal(isUnknownFieldRejection(400, null, 'resolver_worktree'), false);
|
|
256
|
+
assert.equal(isUnknownFieldRejection(400, { error: 'flat_string_error' }, 'resolver_worktree'), false);
|
|
257
|
+
});
|