@bongos/core 1.19.315 → 1.19.317
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 +104 -29
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +35 -27
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/skill-preflight.js +336 -0
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/skill_preflight.mjs +251 -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
|
+
};
|