@bongos/core 1.19.316 → 1.19.318

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.
Files changed (42) hide show
  1. package/.bongos-core.json +108 -33
  2. package/modules/economy/cost.js +84 -19
  3. package/modules/lifecycle/db-analytics.js +719 -0
  4. package/modules/lifecycle/db-claim-reads.js +165 -0
  5. package/modules/lifecycle/db-claims.js +934 -0
  6. package/modules/lifecycle/db-deps-criteria.js +441 -0
  7. package/modules/lifecycle/db-goals.js +763 -0
  8. package/modules/lifecycle/db-grade.js +233 -0
  9. package/modules/lifecycle/db-overrides.js +181 -0
  10. package/modules/lifecycle/db-rank-authz.js +446 -0
  11. package/modules/lifecycle/db-shared.js +115 -0
  12. package/modules/lifecycle/db-ship.js +994 -0
  13. package/modules/lifecycle/db-tasks.js +810 -0
  14. package/modules/lifecycle/db-versions.js +118 -0
  15. package/modules/lifecycle/db.js +167 -5564
  16. package/modules/lifecycle/publish-reconciler.js +7 -2
  17. package/package-lock.json +2 -2
  18. package/package.json +1 -1
  19. package/scripts/gds/coreB-carve-proof.sh +18 -9
  20. package/scripts/gds/fitness.js +34 -26
  21. package/scripts/gds/identity-strip.js +75 -0
  22. package/scripts/gds/provision.js +7 -3
  23. package/scripts/gds/run-unit-tests.js +8 -1
  24. package/scripts/gds/session-cost.js +16 -0
  25. package/scripts/gds/session-digest-build.js +261 -0
  26. package/scripts/gds/ship.js +7 -198
  27. package/src/module-api.js +5 -1
  28. package/tests/auto_satisfy_criteria.mjs +3 -2
  29. package/tests/dependencies_migration.mjs +3 -2
  30. package/tests/fitness.mjs +43 -0
  31. package/tests/goal_abuse_matrix.mjs +3 -2
  32. package/tests/goal_advisory.mjs +3 -2
  33. package/tests/government_claim_eligibility.mjs +3 -1
  34. package/tests/government_rank_sync.mjs +9 -2
  35. package/tests/helpers.mjs +19 -0
  36. package/tests/migration_allocation.mjs +3 -1
  37. package/tests/publish_reconciler.mjs +41 -1
  38. package/tests/rank_gate.mjs +3 -1
  39. package/tests/reward_gate.mjs +3 -1
  40. package/tests/session_cost.mjs +64 -0
  41. package/tests/session_cost_row_raise.mjs +139 -0
  42. package/tests/session_records.mjs +72 -0
@@ -0,0 +1,934 @@
1
+ // modules/lifecycle/db-claims.js — the claim refusal ladder — claimTask, batch claim/release + batch validation.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 2540-3441 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../src/module-api');
14
+ const { pool, withTx } = api;
15
+ const log = api.logger('lifecycle');
16
+ const seams = api;
17
+ const { touchesOverlap } = api.pathMatch;
18
+ const { claimEligibilityAllows, CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
19
+ const { goalMembershipFailures } = require('./claim-feed.js');
20
+ const { unsatisfiedDeps } = require('./db-deps-criteria.js');
21
+ const { claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName, rankAllowsTask, xenosClaimAllowed } = require('./db-rank-authz.js');
22
+ const { sealSessionLog } = require('./db-shared.js');
23
+ const { needsRebaseTasksForBuilder } = require('./db-ship.js');
24
+ const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
25
+
26
+ // -------------------------------------------------------------------------
27
+ // Claims (atomic)
28
+ // -------------------------------------------------------------------------
29
+
30
+ // Tries to atomically claim a task. Returns the claim row on success.
31
+ // Throws { code: 'ALREADY_CLAIMED' } if another builder beat us to it.
32
+ // Throws { code: 'TASK_NOT_READY' } if the task isn't in 'ready'.
33
+ // Throws { code: 'TOUCHES_CONFLICT' } if a different active claim has
34
+ // overlapping touches[] (caught BEFORE the unique-violation, so we
35
+ // give a useful error rather than a generic constraint failure).
36
+
37
+ // task 1002589 (idea 1000638) — a claims INSERT can trip TWO partial unique
38
+ // indexes, and each means something different to the caller:
39
+ // uniq_active_claim_per_task — race lost: another session claimed the task.
40
+ // the per-session backstop — this session already holds a bound active claim
41
+ // in the same working tree. Collapsing this case onto ALREADY_CLAIMED sent
42
+ // builders hunting a phantom rival claimant while the target task sat
43
+ // 'ready'.
44
+ // Both generations of the backstop index map to the honest code: a deploy
45
+ // applies code and migration at different instants, and an instance may still
46
+ // carry the old index.
47
+ const SESSION_CLAIM_INDEXES = new Set([
48
+ 'uniq_active_claim_per_session', // migration 180 — session-only (pre-re-key)
49
+ 'uniq_active_claim_per_session_worktree', // core_204 (task 1002570) — per (session, stored worktree)
50
+ // core_221 (task 1002709) — per (session, REQUESTED worktree). All three
51
+ // generations stay listed: a deploy applies code and migration at different
52
+ // instants, so an instance may still carry an older index, and an unrecognised
53
+ // constraint falls closed onto the generic ALREADY_CLAIMED — which for this
54
+ // case is the wrong, phantom-rival answer.
55
+ 'uniq_active_claim_per_session_requested_worktree',
56
+ ]);
57
+ // Branch on err.constraint; an UNKNOWN constraint fails CLOSED on the generic
58
+ // ALREADY_CLAIMED (never widen a claim refusal). Pure + exported for DB-free
59
+ // tests; claimTask enriches the session case with the in-flight claim's id.
60
+ function claimUniqueViolationError(err) {
61
+ if (err && SESSION_CLAIM_INDEXES.has(err.constraint)) {
62
+ return {
63
+ code: 'session_already_has_bound_claim',
64
+ what_to_do: 'Ship or release the in-flight claim first (/builder-ship or /builder-release), '
65
+ + 'or claim this task from its own separate worktree (since core_204 the index allows '
66
+ + 'one bound claim per worktree). {"bind_session":false} (an unbound claim) always coexists.',
67
+ };
68
+ }
69
+ return { code: 'ALREADY_CLAIMED' };
70
+ }
71
+
72
+ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
73
+ const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
74
+ const client = await pool.connect();
75
+ try {
76
+ await client.query('BEGIN');
77
+
78
+ // V3.R88 (#307): a deactivated builder cannot claim new work. Their
79
+ // history is preserved, but they're out of the active rotation until an
80
+ // Archon reactivates them.
81
+ const { rows: builderRows } = await client.query(
82
+ `SELECT status, rank FROM builders WHERE id = $1`,
83
+ [builderId]
84
+ );
85
+ if (builderRows[0] && builderRows[0].status === 'inactive') {
86
+ throw { code: 'BUILDER_INACTIVE' };
87
+ }
88
+ const builderRank = builderRows[0] ? builderRows[0].rank : null;
89
+
90
+ // ADR 0120 part 3: the needs_rebase claim gate. A builder who owes a rebase
91
+ // on any un-landed confirmed task (flagged by the reconciler, part 2) is
92
+ // blocked from taking on NEW work until they LAND it (rebase the branch +
93
+ // re-ship → reconciler) or RELEASE it — both clear the flag. Their
94
+ // already-active claims are untouched (we only refuse fresh claims, never
95
+ // freeze in-flight work). Since the reward now lands only on ship (part 1),
96
+ // the builder is motivated to clear it; since it gates their queue, they
97
+ // can't walk away from it. Runs in THIS txn so it can't race a concurrent
98
+ // ship that clears the last flag.
99
+ const owedRebases = await needsRebaseTasksForBuilder(builderId, client);
100
+ if (owedRebases.length > 0) {
101
+ throw {
102
+ code: 'REBASE_REQUIRED',
103
+ rebaseTasks: owedRebases.map((t) => ({ id: t.id, title: t.title, reason: t.needs_rebase_reason })),
104
+ };
105
+ }
106
+
107
+ const { rows: taskRows } = await client.query(
108
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id FROM tasks WHERE id = $1 FOR UPDATE`,
109
+ [taskId]
110
+ );
111
+ if (taskRows.length === 0) {
112
+ throw { code: 'TASK_NOT_FOUND' };
113
+ }
114
+ const task = taskRows[0];
115
+ if (task.status !== 'ready') {
116
+ throw { code: 'TASK_NOT_READY', currentStatus: task.status };
117
+ }
118
+
119
+ // task 1002650 (idea 1000644): the claimable FEED filters out ready tasks
120
+ // with an open blocker link, but the direct claim-by-id path never checked —
121
+ // the feed hid the trap and this path walked into it. Mirror migration 176's
122
+ // task_is_fully_unblocked clause so both gates enforce the same rule.
123
+ const { rows: openBlockers } = await client.query(
124
+ `SELECT b.id, b.title
125
+ FROM task_blockers tb
126
+ JOIN blockers b ON b.id = tb.blocker_id
127
+ WHERE tb.task_id = $1
128
+ AND b.status = 'open'
129
+ ORDER BY b.id`,
130
+ [taskId]
131
+ );
132
+ if (openBlockers.length > 0) {
133
+ throw { code: 'OPEN_BLOCKER', blockers: openBlockers };
134
+ }
135
+
136
+ // Three-rank model (task #360 / ADR 0018; V3.R15 #239): a Xenos is the
137
+ // sandboxed entry rank and may claim ONLY tasks explicitly opened to them
138
+ // (newcomer_friendly). Metic+ (the approved working rank) and Archon claim
139
+ // any ready task. This is the server-enforced half of C2's "Xenos ships
140
+ // only explicitly-tagged-open tasks" — markdown describes it, this check
141
+ // enforces it. Error code is rank_too_low_for_task (V3.R15); XENOS_RESTRICTED
142
+ // is kept as an alias on the error object for callers on the pre-rename
143
+ // release. BV1.R106 (ADR 0151): same rule, now decided on the PERMISSIONS
144
+ // task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
145
+ const newcomerFriendly = task.newcomer_friendly;
146
+ if (!claimEligibilityAllows({ heldPermissions, newcomerFriendly, rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly) })) {
147
+ // task 1002613 (map §4.4): the bare code reported a PERMISSION decision as
148
+ // a rank one — no axis, no remedy. Copy INSUFFICIENT_RANK's shape below:
149
+ // name both sides of the wall and the way forward.
150
+ const opened = newcomerFriendly === true;
151
+ throw {
152
+ code: 'rank_too_low_for_task',
153
+ legacy_code: 'XENOS_RESTRICTED',
154
+ required: opened ? [CLAIM_ANY, CLAIM_NEWCOMER] : [CLAIM_ANY],
155
+ actual: builderRank,
156
+ newcomer_friendly: opened,
157
+ message: opened
158
+ ? `claiming this task needs ${CLAIM_ANY} or ${CLAIM_NEWCOMER}, and your role holds neither`
159
+ : `this task is not opened to newcomers (newcomer_friendly is not set) and your role lacks ${CLAIM_ANY}`,
160
+ hint: opened
161
+ ? 'your builder appears to hold no claim permission at all — ask for your rank-role to be re-synced'
162
+ : 'pick a newcomer_friendly task from the claimable feed (/builder-start), or a builder whose role holds task.claim.any can claim this one',
163
+ };
164
+ }
165
+
166
+ // V3.R82 (#301): the inverse gate — a task can RAISE its minimum required
167
+ // rank above the default. requires_rank='metic' blocks Xenos; 'archon'
168
+ // blocks Xenos+Metic. The DB CHECK on tasks.requires_rank enforces the
169
+ // enum, so we trust the value here. INSUFFICIENT_RANK carries the actual
170
+ // and required ranks so the caller can render a clean error.
171
+ if (!rankAllowsTask(builderRank, task.requires_rank)) {
172
+ throw {
173
+ code: 'INSUFFICIENT_RANK',
174
+ required: task.requires_rank,
175
+ actual: builderRank,
176
+ };
177
+ }
178
+
179
+ // task 1003137 (owner ruling 2026-08-19): a task's goal requires MEMBERSHIP —
180
+ // every goal, catch-alls included; goal_id NULL is NOT gated. After the rank
181
+ // gates (membership only narrows — it must never mask a rank refusal),
182
+ // before deps. Decision + leak rule: goal-authz.goalMembershipClaimRefusal;
183
+ // the SAME extracted check runs in claimTasksBatch (the only other INSERT
184
+ // INTO claims site) — see claim-feed.js.
185
+ const goalRefusals = await goalMembershipFailures({ client, tasks: [task], builderId, builderRank });
186
+ if (goalRefusals.length > 0) throw goalRefusals[0].refusal;
187
+
188
+ // The claim gate (ADR 0015, generalized in task 1516 / ADR 0086 §5): a task
189
+ // can't be claimed while any dependency is un-done — its OWN edges plus those
190
+ // inherited from its goal/criteria, where "done" = task shipped | criterion
191
+ // satisfied | goal achieved. The auto-promotion triggers keep backlog tasks
192
+ // gated, but a manually-promoted task could end up 'ready' with pending deps —
193
+ // this is the second line of defence. Wire code stays DEPS_NOT_SHIPPED.
194
+ const unmetDeps = await unsatisfiedDeps(taskId, client);
195
+ if (unmetDeps.length > 0) {
196
+ throw {
197
+ code: 'DEPS_NOT_SHIPPED',
198
+ deps: unmetDeps.map((r) => ({ kind: r.kind, id: r.id, title: r.title, status: r.state })),
199
+ };
200
+ }
201
+
202
+ // task 1670 / ADR 0096 + task 1673 amendment: the reward backstop now
203
+ // AUTO-ASSIGNS instead of blocking. No task ships paying 0 drachmae — but a
204
+ // claim is never REFUSED for it. If credits_reward is 0/NULL at claim time
205
+ // (a backlog task that auto-promoted before 178, a manually-flipped 'ready'
206
+ // row, a reward zeroed after promotion, or one of the ~27 grandfathered
207
+ // reward_gate_exempt tasks), assign the capped suggestion HERE — inside this
208
+ // same FOR UPDATE transaction, so the value is set and the claim commit
209
+ // together atomically. AUDIT: a log line + the visible credits_reward change.
210
+ const rewardAssign = rewardGateAssignment(task);
211
+ if (rewardAssign) {
212
+ await client.query(
213
+ `UPDATE tasks SET credits_reward = $1, updated_at = now() WHERE id = $2`,
214
+ [rewardAssign.assign, taskId]
215
+ );
216
+ task.credits_reward = rewardAssign.assign;
217
+ console.log(`[reward-gate] AUTO-ASSIGN task ${taskId} credits_reward -> ${rewardAssign.assign} (cap ${REWARD_SUGGESTION_CAP}) [claim by builder ${builderId}]`);
218
+ }
219
+
220
+ let overlapWarning = null;
221
+ // touches[] cleanup [5/8] (task 880): prefix-aware overlap vs every OTHER
222
+ // active claim, computed in JS with the canonical matcher. The old SQL `&&`
223
+ // only caught exact-element overlap, so ['src/bongos/'] and ['src/bongos/db.js']
224
+ // slipped past it. Active claims are bounded by the number of concurrent
225
+ // builders, so fetching their touches and comparing in JS is cheap.
226
+ //
227
+ // The result is ADVISORY by default (ADR 0017: a hard 409 turned correct,
228
+ // mergeable work into deferred backlog — git merge is the real collision
229
+ // detector). It becomes a HARD precondition only when enforceOverlap=true,
230
+ // which the autonomous --parallel runner sets: there, two same-footprint
231
+ // tasks fanned into concurrent unsupervised agents could semantically
232
+ // clobber each other in ways git won't flag (parallel_safe covers non-file
233
+ // hazards, not file adjacency).
234
+ const { rows: activeClaimRows } = await client.query(
235
+ `SELECT c.id, t.id AS task_id, t.title, t.touches, t.module_key FROM claims c
236
+ JOIN tasks t ON t.id = c.task_id
237
+ WHERE c.released_at IS NULL AND t.id <> $1`,
238
+ [taskId]
239
+ );
240
+ // BV1.R53 (task 1422): module-disjointness is a HARD gate (not advisory) when
241
+ // both tasks carry a module_key. Two tasks in the same module are always a
242
+ // semantic conflict — their work inherently overlaps regardless of which files
243
+ // they predict. Two tasks in DIFFERENT modules are always safe (file-path
244
+ // overlap between modules is a fitness-function violation, not a runtime
245
+ // concern). module_key disjointness supersedes the touches[] check for
246
+ // module-keyed tasks and is never advisory.
247
+ if (task.module_key) {
248
+ const sameModuleConflicts = activeClaimRows.filter((r) => r.module_key === task.module_key);
249
+ if (sameModuleConflicts.length > 0) {
250
+ throw {
251
+ code: 'MODULE_CONFLICT',
252
+ module_key: task.module_key,
253
+ conflictTaskTitles: sameModuleConflicts.map((r) => r.title),
254
+ };
255
+ }
256
+ }
257
+ // touches[] overlap check applies only when module_key is absent on either side.
258
+ const overlapping = activeClaimRows.filter(
259
+ (r) => !(task.module_key && r.module_key) && touchesOverlap(task.touches, r.touches)
260
+ );
261
+ if (overlapping.length > 0) {
262
+ const titles = overlapping.map((r) => r.title);
263
+ if (enforceOverlap) {
264
+ throw { code: 'TOUCHES_CONFLICT', conflictTaskTitles: titles };
265
+ }
266
+ overlapWarning = titles;
267
+ }
268
+
269
+ // Pre-allocate a migration number if the task signals it'll add one.
270
+ // Trigger: the tasks.needs_migration boolean (migration 081, touches[]
271
+ // cleanup [1/8] / task 876). Replaced the old touches[]-contains-'migrations/'
272
+ // string sentinel so allocation no longer rides on a drifting path array.
273
+ let reservedMigrationNumber = null;
274
+ if (task.needs_migration === true) {
275
+ const { rows: bumpRows } = await client.query(
276
+ `UPDATE migration_counter
277
+ SET next_number = next_number + 1
278
+ WHERE id = 1
279
+ RETURNING next_number - 1 AS allocated`
280
+ );
281
+ reservedMigrationNumber = bumpRows[0].allocated;
282
+ }
283
+
284
+ // V3.R51 (#270): server-side worktree_name uniqueness across ACTIVE claims.
285
+ // `released_at IS NULL` is exactly the active set (same predicate as the
286
+ // touches-conflict check above and uniq_active_claim_per_task). A name freed
287
+ // by a resolved claim won't appear here, so it's reused without a suffix —
288
+ // which is what the overnight builder relies on. We hold FOR UPDATE on the
289
+ // task row already; reading the active names inside the same txn is good
290
+ // enough for the disambiguation to be stable for this insert.
291
+ // task 1002522: fold ''/whitespace → NULL before anything keys on the name.
292
+ // core_204's index folds them together anyway (COALESCE(worktree_name, ''));
293
+ // normalizing here means the stored row matches what the guard compared.
294
+ // task 1002709: BOTH names are stored, and they answer different questions.
295
+ // `requestedWorktreeName` is what the caller asked for — the identity of the
296
+ // working TREE, which is what "do these two claims share a tree" actually
297
+ // asks. `finalWorktreeName` is what lands on disk after disambiguation, which
298
+ // is what must stay globally unique across active claims.
299
+ //
300
+ // Keying the one-claim-per-tree guard on the STORED name was the hole: a
301
+ // second claim from the same session in the same tree compared "overnight"
302
+ // against a previously-renamed "overnight-2", saw a different tree, and let
303
+ // a commingling claim through. See core_221 for the full repro.
304
+ const requestedWorktreeName = foldWorktreeName(worktreeName);
305
+ let finalWorktreeName = requestedWorktreeName;
306
+ if (finalWorktreeName) {
307
+ const { rows: activeNameRows } = await client.query(
308
+ `SELECT worktree_name FROM claims
309
+ WHERE released_at IS NULL AND worktree_name IS NOT NULL`
310
+ );
311
+ const taken = new Set(activeNameRows.map((r) => r.worktree_name));
312
+ finalWorktreeName = disambiguateWorktreeName(finalWorktreeName, taken);
313
+ }
314
+
315
+ const insertClaim = await client.query(
316
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
317
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
318
+ RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
319
+ [taskId, builderId, finalWorktreeName, requestedWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
320
+ );
321
+
322
+ await client.query(`UPDATE tasks SET status = 'active' WHERE id = $1`, [taskId]);
323
+
324
+ // Gamification carved to modules/economy/ (BV1.R77 / ADR 0093 §1-2): resolve
325
+ // the `reward` kernel port and fire the claim hook inside this same txn.
326
+ const justUnlocked = await seams.resolveOptional('reward').evaluateAfterClaim(
327
+ client, builderId, insertClaim.rows[0].id
328
+ );
329
+
330
+ // V3.R75 (#294): tick the onboarding state machine for first_claim. Done
331
+ // inside the same transaction so the claim and onboarding write commit
332
+ // together. Wrapped in try/catch so an onboarding-table issue can't
333
+ // block a legitimate claim. The onboarding state machine carved into
334
+ // modules/onboarding/ (BV1.R81 / ADR 0093 §1): resolve the `onboarding`
335
+ // kernel port and pass this txn `client` — never a direct require of a
336
+ // module. Default-on, so present in prod; the ?. degrades to a no-op (the
337
+ // state tick is best-effort anyway) when the module is off.
338
+ let graduationBuilder = null;
339
+ try {
340
+ const onboarding = seams.resolveOptional('onboarding');
341
+ const result = onboarding && await onboarding.markStage(client, builderId, 'first_claim');
342
+ if (result && result.justGraduated) {
343
+ const { rows: br } = await client.query(
344
+ `SELECT id, display_name, github_login FROM builders WHERE id = $1`,
345
+ [builderId]
346
+ );
347
+ graduationBuilder = br[0] || null;
348
+ }
349
+ } catch (e) {
350
+ console.log('[gds] claimTask onboarding markStage failed (non-blocking):', e && e.message);
351
+ }
352
+
353
+ await client.query('COMMIT');
354
+ if (graduationBuilder) {
355
+ try {
356
+ seams.resolveOptional('onboarding')?.broadcastGraduation(graduationBuilder);
357
+ } catch (e) {
358
+ console.log('[gds] graduation broadcast post-claim failed:', e && e.message);
359
+ }
360
+ }
361
+ return { ...insertClaim.rows[0], just_unlocked: justUnlocked, overlap_warning: overlapWarning };
362
+ } catch (err) {
363
+ await client.query('ROLLBACK').catch(() => {});
364
+ if (err && err.code === '23505') {
365
+ // task 1002589: branch on WHICH unique index tripped — see
366
+ // claimUniqueViolationError above. The session case is enriched with the
367
+ // in-flight bound claim (post-ROLLBACK, same client) so the refusal
368
+ // points at the real cause; best-effort — the honest code must not
369
+ // depend on this read succeeding.
370
+ let mapped = claimUniqueViolationError(err);
371
+ if (mapped.code === 'session_already_has_bound_claim' && creatorSessionId) {
372
+ try {
373
+ // Same-worktree row first: under the core_204 index that is the
374
+ // exact colliding claim; under the old session-only index any bound
375
+ // row of the session is the culprit, so fall back to the earliest.
376
+ const { rows } = await client.query(
377
+ `SELECT c.task_id, t.title
378
+ FROM claims c JOIN tasks t ON t.id = c.task_id
379
+ WHERE c.creator_session_id = $1 AND c.released_at IS NULL
380
+ ORDER BY (COALESCE(c.worktree_name, '') = COALESCE($2, '')) DESC, c.claimed_at, c.id
381
+ LIMIT 1`,
382
+ [creatorSessionId, worktreeName ?? null]
383
+ );
384
+ if (rows[0]) {
385
+ mapped = { ...mapped, active_task_id: Number(rows[0].task_id), active_task_title: rows[0].title };
386
+ }
387
+ } catch (_) { /* enrichment only — the mapped code still throws */ }
388
+ }
389
+ throw mapped;
390
+ }
391
+ throw err;
392
+ } finally {
393
+ client.release();
394
+ }
395
+ }
396
+
397
+ // Atomically claim N tasks in a single transaction. All-or-nothing.
398
+ //
399
+ // Mirrors claimTask's semantics for each task in the batch but runs inside ONE
400
+ // BEGIN/COMMIT, so a partial failure (any one task being unready / already
401
+ // claimed / touches-conflicting) rolls back the whole batch. The caller is
402
+ // never left holding a half-acquired set.
403
+ //
404
+ // Additional check beyond the single-claim path: inter-batch touches conflict.
405
+ // Within the requested set, no two tasks may have overlapping touches[]. The
406
+ // optimizer should already filter these on the way in, but the server
407
+ // re-validates so a stale client can't sneak through.
408
+ //
409
+ // On failure, throws { code: 'BATCH_FAILED', failures: [{ task_id, reason }] }.
410
+ // Reasons mirror the single-claim error codes (TASK_NOT_FOUND, TASK_NOT_READY,
411
+ // TOUCHES_CONFLICT, ALREADY_CLAIMED) plus 'BATCH_TOUCHES_CONFLICT' for
412
+ // intra-batch overlap.
413
+
414
+ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSessionId }) {
415
+ if (!Array.isArray(taskIds) || taskIds.length === 0) {
416
+ throw { code: 'BATCH_EMPTY' };
417
+ }
418
+ const uniqIds = Array.from(new Set(taskIds.map(Number)));
419
+ if (uniqIds.length !== taskIds.length) {
420
+ throw { code: 'BATCH_DUPLICATE_IDS' };
421
+ }
422
+ // Stable-sort the IDs before SELECT FOR UPDATE so two concurrent batches
423
+ // sharing any rows can't deadlock by locking in opposite orders.
424
+ uniqIds.sort((a, b) => a - b);
425
+
426
+ // BV1.R106: resolved BEFORE the tx opens — see claimPermissionsFor.
427
+ const heldPermissions = await claimPermissionsFor(builderId);
428
+ const client = await pool.connect();
429
+ try {
430
+ await client.query('BEGIN');
431
+
432
+ // V3.R88 (#307): a deactivated builder can't batch-claim either.
433
+ const { rows: builderRows } = await client.query(
434
+ `SELECT status, rank FROM builders WHERE id = $1`,
435
+ [builderId]
436
+ );
437
+ if (builderRows[0] && builderRows[0].status === 'inactive') {
438
+ throw { code: 'BUILDER_INACTIVE' };
439
+ }
440
+ const builderRank = builderRows[0] ? builderRows[0].rank : null;
441
+
442
+ // Lock every requested task row up front (sorted to avoid deadlocks).
443
+ const { rows: taskRows } = await client.query(
444
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, goal_id FROM tasks WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE`,
445
+ [uniqIds]
446
+ );
447
+
448
+ const failures = [];
449
+ // node-pg returns bigint/bigserial columns (tasks.id) as JS STRINGS, not
450
+ // numbers (learning #94) — Number() them before comparing against uniqIds
451
+ // (which are already numbers). Without this, foundIds.has(requestedId)
452
+ // never matches ("1781" !== 1781) and EVERY requested id is wrongly
453
+ // reported TASK_NOT_FOUND, even ones the SELECT above found fine.
454
+ const foundIds = new Set(taskRows.map((r) => Number(r.id)));
455
+ for (const requestedId of uniqIds) {
456
+ if (!foundIds.has(requestedId)) {
457
+ failures.push({ task_id: requestedId, reason: 'TASK_NOT_FOUND' });
458
+ }
459
+ }
460
+ for (const t of taskRows) {
461
+ if (t.status !== 'ready') {
462
+ failures.push({ task_id: t.id, reason: 'TASK_NOT_READY', currentStatus: t.status });
463
+ }
464
+ }
465
+
466
+ // task 1002650 (idea 1000644): same open-blocker rule as claimTask — the
467
+ // batch path must not sidestep the gate the feed and single claim enforce.
468
+ const { rows: blockedRows } = await client.query(
469
+ `SELECT tb.task_id, b.id, b.title
470
+ FROM task_blockers tb
471
+ JOIN blockers b ON b.id = tb.blocker_id
472
+ WHERE tb.task_id = ANY($1::bigint[])
473
+ AND b.status = 'open'
474
+ ORDER BY b.id`,
475
+ [uniqIds]
476
+ );
477
+ const blockersByTask = new Map();
478
+ for (const r of blockedRows) {
479
+ const list = blockersByTask.get(Number(r.task_id)) || [];
480
+ list.push({ id: r.id, title: r.title });
481
+ blockersByTask.set(Number(r.task_id), list);
482
+ }
483
+ for (const [blockedTaskId, blockers] of blockersByTask) {
484
+ failures.push({ task_id: blockedTaskId, reason: 'OPEN_BLOCKER', blockers });
485
+ }
486
+
487
+ // Three-rank model (task #360 / ADR 0018; V3.R15 #239): a Xenos may
488
+ // batch-claim only tasks explicitly opened to them (newcomer_friendly).
489
+ // Mirrors the single claimTask gate so the batch path can't be used to
490
+ // sidestep the sandbox. Failure reason carries both names — the new
491
+ // canonical code and the legacy alias — so callers on either release work.
492
+ //
493
+ // BV1.R106 (ADR 0151): the inner decision now reads the PERMISSION atoms, as
494
+ // in claimTask. The outer `builderRank === 'xenos'` short-circuit is LEFT AS
495
+ // IS: it is not a fast path but a semantic one (a reserved/unwired rank is
496
+ // never checked here, while claimTask checks every rank), so removing it would
497
+ // change who can batch-claim. That pre-existing asymmetry is reported, not
498
+ // fixed, under this task — closing it is an authority decision of its own.
499
+ if (builderRank === 'xenos') {
500
+ for (const t of taskRows) {
501
+ if (!claimEligibilityAllows({
502
+ heldPermissions,
503
+ newcomerFriendly: t.newcomer_friendly,
504
+ rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
505
+ })) {
506
+ failures.push({ task_id: t.id, reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
507
+ }
508
+ }
509
+ }
510
+
511
+ // V3.R82 (#301): the per-task requires_rank floor. Mirrors the single
512
+ // claimTask gate so a Metic can't slip through batch-claim to grab an
513
+ // Archon-only task. The xenos sandbox check above handles the xenos case
514
+ // exhaustively for requires_rank='xenos' (the default); this gate refuses
515
+ // requires_rank='metic'/'archon' when the builder is below.
516
+ for (const t of taskRows) {
517
+ if (!rankAllowsTask(builderRank, t.requires_rank)) {
518
+ failures.push({
519
+ task_id: t.id,
520
+ reason: 'INSUFFICIENT_RANK',
521
+ required: t.requires_rank,
522
+ actual: builderRank,
523
+ });
524
+ }
525
+ }
526
+
527
+ // task 1003137: the SAME extracted goal-membership gate as claimTask — this
528
+ // path owns the repo's only other INSERT INTO claims, so an inline-only gate
529
+ // would be bypassable via batch. Same ladder position: after rank, before deps.
530
+ for (const f of await goalMembershipFailures({ client, tasks: taskRows, builderId, builderRank })) {
531
+ const { code, ...rest } = f.refusal;
532
+ failures.push({ task_id: f.task_id, reason: code, ...rest });
533
+ }
534
+
535
+ // Per-task dependency check (ADR 0015, generalized task 1516). An EXTERNAL dep
536
+ // (not in this batch) must already be done; a batch-internal dep isn't done yet
537
+ // at claim time but doesn't block — the batch is worked serially. "Internal"
538
+ // applies ONLY to task-kind deps: a goal/criterion dep can never be a task id in
539
+ // the batch, so it is always external (must not be filtered out).
540
+ const batchIdSet = new Set(uniqIds);
541
+ for (const t of taskRows) {
542
+ const unmet = await unsatisfiedDeps(t.id, client);
543
+ const external = unmet.filter((d) => !(d.kind === 'task' && batchIdSet.has(Number(d.id))));
544
+ if (external.length > 0) {
545
+ failures.push({
546
+ task_id: t.id,
547
+ reason: 'DEPS_NOT_SHIPPED',
548
+ deps: external.map((d) => ({ kind: d.kind, id: d.id, title: d.title, status: d.state })),
549
+ });
550
+ }
551
+ }
552
+
553
+ // BV1.R53 (task 1422): module-disjointness within the batch — two tasks in
554
+ // the same module are a hard conflict (batch is the autonomous --parallel
555
+ // path; module-disjointness is always enforced there).
556
+ for (let i = 0; i < taskRows.length; i++) {
557
+ for (let j = i + 1; j < taskRows.length; j++) {
558
+ const a = taskRows[i];
559
+ const b = taskRows[j];
560
+ if (a.module_key && b.module_key && a.module_key === b.module_key) {
561
+ failures.push({
562
+ task_id: b.id,
563
+ reason: 'MODULE_CONFLICT',
564
+ module_key: a.module_key,
565
+ conflictsWithTaskId: a.id,
566
+ });
567
+ }
568
+ }
569
+ }
570
+
571
+ // touches[] cleanup [5/8] (task 880): inter-batch overlap via the canonical
572
+ // prefix-aware matcher (was JS Set-equality, which missed dir-prefix
573
+ // adjacency like ['src/bongos/'] vs ['src/bongos/db.js']). Batch claims are the
574
+ // autonomous --parallel path, so the overlap gate stays HARD here.
575
+ // Skip the touches check for pairs where both tasks have module_key set —
576
+ // module-disjointness already covers them (either same-module = conflict above,
577
+ // or different-module = always safe regardless of predicted file paths).
578
+ for (let i = 0; i < taskRows.length; i++) {
579
+ for (let j = i + 1; j < taskRows.length; j++) {
580
+ const a = taskRows[i];
581
+ const b = taskRows[j];
582
+ if (a.module_key && b.module_key) continue; // module-disjointness handled above
583
+ if (touchesOverlap(a.touches, b.touches)) {
584
+ failures.push({
585
+ task_id: b.id,
586
+ reason: 'BATCH_TOUCHES_CONFLICT',
587
+ conflictsWithTaskId: a.id,
588
+ });
589
+ }
590
+ }
591
+ }
592
+
593
+ // Touches conflict against OTHER builders' active claims (prefix-aware).
594
+ // Fetch every active claim's touches (can't SQL-prefilter with `&&` — that
595
+ // would miss dir-prefix adjacency) and compare with the canonical matcher.
596
+ const { rows: activeClaims } = await client.query(
597
+ `SELECT c.id AS claim_id, c.task_id AS conflict_task_id, t.title, t.touches, t.module_key
598
+ FROM claims c
599
+ JOIN tasks t ON t.id = c.task_id
600
+ WHERE c.released_at IS NULL
601
+ AND t.id <> ALL($1::bigint[])`,
602
+ [uniqIds]
603
+ );
604
+ for (const t of taskRows) {
605
+ // BV1.R53: module-disjointness against other builders' active claims.
606
+ if (t.module_key) {
607
+ for (const cr of activeClaims) {
608
+ if (cr.module_key === t.module_key) {
609
+ failures.push({
610
+ task_id: t.id,
611
+ reason: 'MODULE_CONFLICT',
612
+ module_key: t.module_key,
613
+ conflictTaskTitle: cr.title,
614
+ conflictClaimId: cr.claim_id,
615
+ });
616
+ }
617
+ }
618
+ }
619
+ for (const cr of activeClaims) {
620
+ if (t.module_key && cr.module_key) continue; // both have module_key → handled above
621
+ if (touchesOverlap(t.touches, cr.touches)) {
622
+ failures.push({
623
+ task_id: t.id,
624
+ reason: 'TOUCHES_CONFLICT',
625
+ conflictTaskTitle: cr.title,
626
+ conflictClaimId: cr.claim_id,
627
+ });
628
+ }
629
+ }
630
+ }
631
+
632
+ if (failures.length > 0) {
633
+ throw { code: 'BATCH_FAILED', failures };
634
+ }
635
+
636
+ // All checks passed. Insert N claim rows, allocating per-task migration
637
+ // numbers if the touches[] signals it. The unique-violation safety net is
638
+ // still here: if another batch beat us to a row between our SELECT FOR
639
+ // UPDATE and INSERT (it can't, given the row lock, but defense in depth),
640
+ // we surface that as ALREADY_CLAIMED for the specific task.
641
+ //
642
+ // task 1802 — uniq_active_claim_per_session (migration 180) was keyed on
643
+ // creator_session_id alone, so it couldn't tell "N rows from one batch call"
644
+ // from "N rows from N separate /claims calls": every row here used to carry
645
+ // the SAME non-null creatorSessionId for a real (bound) CLI session, so the
646
+ // 2nd INSERT always hit the partial unique index. (core_204 / task
647
+ // 1002570 re-keyed it per (session, worktree) — but a prefix-less batch
648
+ // stores NULL worktree_name on every row, which the index folds into ONE
649
+ // group, so first-row-only binding stays load-bearing.) Each batch-claimed task
650
+ // also gets its OWN worktree name (worktreePrefix-taskId, right below) — a
651
+ // batch is claiming N tasks for N future (possibly parallel) working
652
+ // sessions, not one shared worktree — so there is no correctness reason to
653
+ // bind every row to the session that placed the batch call. Bind only the
654
+ // FIRST row (preserves today's exact behavior for the common size-1 batch,
655
+ // and keeps this session's own "at most one BOUND active claim" invariant
656
+ // intact); every later row in the batch is inserted session-unbound
657
+ // (creator_session_id=NULL), same as a bind_session:false web claim —
658
+ // adoptable by any of the builder's own CLI sessions when they pick it up.
659
+ const inserted = [];
660
+ for (let i = 0; i < taskRows.length; i++) {
661
+ const t = taskRows[i];
662
+ let reservedMigrationNumber = null;
663
+ if (t.needs_migration === true) {
664
+ const { rows: bumpRows } = await client.query(
665
+ `UPDATE migration_counter
666
+ SET next_number = next_number + 1
667
+ WHERE id = 1
668
+ RETURNING next_number - 1 AS allocated`
669
+ );
670
+ reservedMigrationNumber = bumpRows[0].allocated;
671
+ }
672
+ // task 1002522: fold the prefix the same way a single claim's name is
673
+ // folded, so a blank/whitespace prefix yields worktree-less rows rather
674
+ // than names like " -1234" that the index would key as distinct trees.
675
+ const foldedPrefix = foldWorktreeName(worktreePrefix);
676
+ const worktreeName = foldedPrefix
677
+ ? `${foldedPrefix}-${t.id}`
678
+ : null;
679
+ const rowCreatorSessionId = i === 0 ? (creatorSessionId ?? null) : null;
680
+ try {
681
+ // task 1002709: requested == final on this path by construction — each
682
+ // row gets its own `<prefix>-<taskId>` tree and nothing disambiguates
683
+ // them. It is still written explicitly rather than left NULL: core_221's
684
+ // index keys on COALESCE(requested_worktree_name, ''), so a NULL here
685
+ // would make a row that HAS a worktree read as worktree-less and collide
686
+ // with the shared-clone group.
687
+ const insertClaim = await client.query(
688
+ `INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
689
+ VALUES ($1, $2, $3, $3, NULL, $4, $5)
690
+ RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
691
+ [t.id, builderId, worktreeName, reservedMigrationNumber, rowCreatorSessionId]
692
+ );
693
+ inserted.push(insertClaim.rows[0]);
694
+ } catch (err) {
695
+ if (err && err.code === '23505') {
696
+ // task 1002589: same constraint branch as claimTask — the per-session
697
+ // index can trip here on the FIRST (bound) row when the calling
698
+ // session already holds a bound claim; every other 23505 stays the
699
+ // per-task race answer.
700
+ throw { code: 'BATCH_FAILED', failures: [{ task_id: t.id, reason: claimUniqueViolationError(err).code }] };
701
+ }
702
+ throw err;
703
+ }
704
+ await client.query(`UPDATE tasks SET status = 'active' WHERE id = $1`, [t.id]);
705
+ }
706
+
707
+ await client.query('COMMIT');
708
+ return { claims: inserted };
709
+ } catch (err) {
710
+ await client.query('ROLLBACK').catch(() => {});
711
+ throw err;
712
+ } finally {
713
+ client.release();
714
+ }
715
+ }
716
+
717
+ // Bulk release (task 1758): release N of the caller's OWN claims in one call —
718
+ // the batch twin of the single-claim resolve(outcome='abandoned') path
719
+ // (release.js / the work board's doRelease both use that path; this is the
720
+ // same effect, N at a time). Unlike claimTasksBatch, this is NOT all-or-nothing:
721
+ // a release has no cross-claim invariant to protect (no touches/module/
722
+ // dependency check makes sense between two claims being given back), so each
723
+ // claim_id stands alone. One bad id (not found, not yours, already resolved,
724
+ // session-locked) fails ONLY that item — every other valid id in the batch
725
+ // still releases. Mirrors resolveClaim's per-claim effects (released_at +
726
+ // outcome, task → 'ready', sealSessionLog, actual_minutes recompute) and
727
+ // releaseClaimOnBehalf's row-locked batch-read shape.
728
+ async function releaseClaimsBatch({ claimIds, builderId, resolverSessionId, force }) {
729
+ if (!Array.isArray(claimIds) || claimIds.length === 0) {
730
+ throw { code: 'BATCH_EMPTY' };
731
+ }
732
+ // Stable-sort before locking so two concurrent batches sharing a row can't
733
+ // deadlock by locking in opposite orders (claimTasksBatch precedent).
734
+ const uniqIds = Array.from(new Set(claimIds.map(Number))).sort((a, b) => a - b);
735
+
736
+ return withTx(async (client) => {
737
+ const { rows: claimRows } = await client.query(
738
+ `SELECT c.id, c.task_id, c.builder_id, c.released_at, c.claimed_at,
739
+ c.worktree_name, c.creator_session_id, t.title
740
+ FROM claims c JOIN tasks t ON t.id = c.task_id
741
+ WHERE c.id = ANY($1::bigint[]) FOR UPDATE`,
742
+ [uniqIds]
743
+ );
744
+ // node-pg returns claims.id (bigserial) as a STRING (learning #94) — Number()
745
+ // it so lookups below (byId.get(claimId), claimId from uniqIds) actually hit.
746
+ const byId = new Map(claimRows.map((r) => [Number(r.id), r]));
747
+
748
+ const released = [];
749
+ const failures = [];
750
+ for (const claimId of uniqIds) {
751
+ const claim = byId.get(claimId);
752
+ if (!claim) { failures.push({ claim_id: claimId, reason: 'CLAIM_NOT_FOUND' }); continue; }
753
+ if (claim.builder_id !== builderId) { failures.push({ claim_id: claimId, reason: 'NOT_YOUR_CLAIM' }); continue; }
754
+ if (claim.released_at) { failures.push({ claim_id: claimId, reason: 'ALREADY_RESOLVED' }); continue; }
755
+ if (!force && claim.creator_session_id && resolverSessionId &&
756
+ claim.creator_session_id !== resolverSessionId) {
757
+ failures.push({ claim_id: claimId, reason: 'CLAIM_SESSION_MISMATCH', claimWorktree: claim.worktree_name ?? null });
758
+ continue;
759
+ }
760
+
761
+ await client.query(
762
+ `UPDATE claims SET released_at = now(), outcome = 'abandoned' WHERE id = $1`,
763
+ [claimId]
764
+ );
765
+ await client.query(
766
+ // ADR 0120 part 2: releasing a task frees it from the builder's queue,
767
+ // so clear any needs_rebase flag — the rebase is no longer their chore
768
+ // (this is the "release to unblock" exit the ADR guarantees).
769
+ `UPDATE tasks
770
+ SET status = 'ready', needs_rebase = false, needs_rebase_reason = NULL,
771
+ needs_rebase_at = NULL, updated_at = now()
772
+ WHERE id = $1`,
773
+ [claim.task_id]
774
+ );
775
+ await sealSessionLog(client, {
776
+ builderId, taskId: claim.task_id, claimId, startedAt: claim.claimed_at,
777
+ outcome: 'abandoned', notesMd: null,
778
+ });
779
+ await client.query(
780
+ `UPDATE tasks
781
+ SET actual_minutes = (
782
+ SELECT COALESCE(SUM(EXTRACT(EPOCH FROM (ended_at - started_at)) / 60)::integer, 0)
783
+ FROM session_logs WHERE task_id = $1
784
+ )
785
+ WHERE id = $1`,
786
+ [claim.task_id]
787
+ );
788
+ released.push({ claim_id: claimId, task_id: claim.task_id, task_title: claim.title });
789
+ }
790
+
791
+ return { released, failures };
792
+ });
793
+ }
794
+
795
+ // Read-only dry-run twin of claimTasksBatch's pre-flight checks (task 1758).
796
+ // Validates an arbitrary task_id set for parallel-safety + sequencing WITHOUT
797
+ // claiming anything — no row lock, no write — so the hall's multi-select
798
+ // toolbar can grey out invalid actions before a real claim. Mirrors
799
+ // claimTasksBatch's checks (rank, dependencies, module-disjointness, touches
800
+ // overlap — both within the set and against other builders' active claims)
801
+ // but collects EVERY failure per task instead of stopping at the first one.
802
+ // Keep the check set in sync with claimTasksBatch if either changes.
803
+ async function validateClaimBatch({ taskIds, builderId }) {
804
+ if (!Array.isArray(taskIds) || taskIds.length === 0) {
805
+ throw { code: 'BATCH_EMPTY' };
806
+ }
807
+ const uniqIds = Array.from(new Set(taskIds.map(Number)));
808
+
809
+ const { rows: builderRows } = await pool.query(
810
+ `SELECT status, rank FROM builders WHERE id = $1`,
811
+ [builderId]
812
+ );
813
+ const builderInactive = builderRows[0] ? builderRows[0].status === 'inactive' : false;
814
+ const builderRank = builderRows[0] ? builderRows[0].rank : null;
815
+
816
+ const { rows: taskRows } = await pool.query(
817
+ `SELECT id, status, touches, newcomer_friendly, requires_rank, module_key, goal_id
818
+ FROM tasks WHERE id = ANY($1::bigint[])`,
819
+ [uniqIds]
820
+ );
821
+ // node-pg returns tasks.id (bigserial) as a STRING (learning #94) — Number()
822
+ // it so lookups below (byId.get(id), id from uniqIds) actually hit.
823
+ const byId = new Map(taskRows.map((r) => [Number(r.id), r]));
824
+
825
+ // Other builders' active claims — for the cross-batch touches/module check,
826
+ // same query shape as claimTasksBatch.
827
+ const { rows: activeClaims } = await pool.query(
828
+ `SELECT c.id AS claim_id, t.title, t.touches, t.module_key
829
+ FROM claims c JOIN tasks t ON t.id = c.task_id
830
+ WHERE c.released_at IS NULL AND t.id <> ALL($1::bigint[])`,
831
+ [uniqIds]
832
+ );
833
+
834
+ const reasonsById = new Map(uniqIds.map((id) => [id, []]));
835
+ const push = (id, reason) => reasonsById.get(id).push(reason);
836
+
837
+ // BV1.R106: same permission-expressed queue gate as claimTasksBatch (including
838
+ // its `builderRank === 'xenos'` short-circuit), resolved once for the whole set
839
+ // — authority is per-request, not per-row.
840
+ const heldPermissions = await claimPermissionsFor(builderId);
841
+
842
+ for (const id of uniqIds) {
843
+ if (builderInactive) push(id, { reason: 'BUILDER_INACTIVE' });
844
+ const t = byId.get(id);
845
+ if (!t) { push(id, { reason: 'TASK_NOT_FOUND' }); continue; }
846
+ if (t.status !== 'ready') push(id, { reason: 'TASK_NOT_READY', currentStatus: t.status });
847
+ if (builderRank === 'xenos' && !claimEligibilityAllows({
848
+ heldPermissions,
849
+ newcomerFriendly: t.newcomer_friendly,
850
+ rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
851
+ })) {
852
+ push(id, { reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
853
+ }
854
+ if (!rankAllowsTask(builderRank, t.requires_rank)) {
855
+ push(id, { reason: 'INSUFFICIENT_RANK', required: t.requires_rank, actual: builderRank });
856
+ }
857
+ }
858
+
859
+ // task 1003137: goal-membership, kept in sync with claimTasksBatch (see claim-feed.js).
860
+ for (const f of await goalMembershipFailures({ client: pool, tasks: taskRows, builderId, builderRank })) {
861
+ const { code, ...rest } = f.refusal;
862
+ push(f.task_id, { reason: code, ...rest });
863
+ }
864
+
865
+ // Dependencies — external to the batch only (a batch-internal dep is fine;
866
+ // the batch is worked serially, same rule as claimTasksBatch).
867
+ const batchIdSet = new Set(uniqIds);
868
+ for (const id of uniqIds) {
869
+ if (!byId.get(id)) continue;
870
+ const unmet = await unsatisfiedDeps(id);
871
+ const external = unmet.filter((d) => !(d.kind === 'task' && batchIdSet.has(Number(d.id))));
872
+ if (external.length > 0) {
873
+ push(id, {
874
+ reason: 'DEPS_NOT_SHIPPED',
875
+ deps: external.map((d) => ({ kind: d.kind, id: d.id, title: d.title, status: d.state })),
876
+ });
877
+ }
878
+ }
879
+
880
+ // Cross-task conflicts within the set — module-disjointness, then touches
881
+ // overlap. Symmetric: a conflicting pair marks BOTH task_ids invalid for
882
+ // PARALLEL (either can still run alone / sequentially after the other).
883
+ for (let i = 0; i < uniqIds.length; i++) {
884
+ const a = byId.get(uniqIds[i]);
885
+ if (!a) continue;
886
+ for (let j = i + 1; j < uniqIds.length; j++) {
887
+ const b = byId.get(uniqIds[j]);
888
+ if (!b) continue;
889
+ if (a.module_key && b.module_key && a.module_key === b.module_key) {
890
+ push(a.id, { reason: 'MODULE_CONFLICT', module_key: a.module_key, conflictsWithTaskId: b.id });
891
+ push(b.id, { reason: 'MODULE_CONFLICT', module_key: a.module_key, conflictsWithTaskId: a.id });
892
+ continue;
893
+ }
894
+ if (a.module_key && b.module_key) continue;
895
+ if (touchesOverlap(a.touches, b.touches)) {
896
+ push(a.id, { reason: 'BATCH_TOUCHES_CONFLICT', conflictsWithTaskId: b.id });
897
+ push(b.id, { reason: 'BATCH_TOUCHES_CONFLICT', conflictsWithTaskId: a.id });
898
+ }
899
+ }
900
+ }
901
+
902
+ // Conflicts against OTHER builders' active claims (prefix-aware touches +
903
+ // module-disjointness) — the check the client can't do on its own, since it
904
+ // has no visibility into claims it doesn't hold.
905
+ for (const id of uniqIds) {
906
+ const t = byId.get(id);
907
+ if (!t) continue;
908
+ for (const cr of activeClaims) {
909
+ if (t.module_key && cr.module_key === t.module_key) {
910
+ push(id, { reason: 'MODULE_CONFLICT', module_key: t.module_key, conflictTaskTitle: cr.title, conflictClaimId: cr.claim_id });
911
+ continue;
912
+ }
913
+ if (t.module_key && cr.module_key) continue;
914
+ if (touchesOverlap(t.touches, cr.touches)) {
915
+ push(id, { reason: 'TOUCHES_CONFLICT', conflictTaskTitle: cr.title, conflictClaimId: cr.claim_id });
916
+ }
917
+ }
918
+ }
919
+
920
+ const results = uniqIds.map((id) => {
921
+ const reasons = reasonsById.get(id);
922
+ return { task_id: id, valid: reasons.length === 0, reasons };
923
+ });
924
+
925
+ return { results, parallel_safe: results.every((r) => r.valid) };
926
+ }
927
+
928
+ module.exports = {
929
+ claimTask,
930
+ claimTasksBatch,
931
+ claimUniqueViolationError,
932
+ releaseClaimsBatch,
933
+ validateClaimBatch,
934
+ };