@bongos/core 1.19.316 → 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.
@@ -0,0 +1,994 @@
1
+ // modules/lifecycle/db-ship.js — the resolve→confirm→ship state machine, landing credit + the rebase/publish flags.
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 3442-4400 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, insertAuditLog } = api;
15
+ const log = api.logger('lifecycle');
16
+ const seams = api;
17
+ const doneWhen = require('./done-when.js');
18
+ const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes } = require('./db-rank-authz.js');
19
+ const { mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
20
+
21
+ // Resolve a claim. Three-state task lifecycle:
22
+ // active → completed (builder declared done; awaiting grader)
23
+ // active → completed → confirmed (promoted by applyGrade when the subagent
24
+ // grader passes; verified but NOT yet paid —
25
+ // ADR 0120 books credits at ship, not confirm)
26
+ // active → ready (abandoned or partial — back to the queue)
27
+ //
28
+ // Caller passes `verification` = { passed: bool, scanner_clean, smoke_clean,
29
+ // diff_stats, notes_md }. `passed` here is the LOCAL verification (scanner +
30
+ // smoke). It is no longer the credits gate — that's the subagent grader, applied
31
+ // in a separate call to applyGrade(). resolveClaim always lands the task at
32
+ // `completed` when passed=true; the caller is expected to invoke the grader and
33
+ // POST the result to /tasks/:id/grade (which calls applyGrade).
34
+ //
35
+ // Returns: { taskId, taskTitle, taskStatus, creditsAwarded, advanceToMerge,
36
+ // justUnlocked, grade }. With the v2 subagent grader, resolveClaim
37
+ // itself never awards credits or advances to confirmed — those happen later
38
+ // via applyGrade. advanceToMerge is therefore always false and grade is null
39
+ // at resolveClaim time. Both keys stay in the response shape so existing
40
+ // callers (ship.js) don't break.
41
+
42
+ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary, verification, resolverSessionId, force, committedFiles = null, sandboxReview = null }) {
43
+ return withTx(async (client) => {
44
+ const { rows: claimRows } = await client.query(
45
+ `SELECT c.id, c.task_id, c.builder_id, c.released_at, c.claimed_at,
46
+ c.worktree_name, c.creator_session_id,
47
+ t.credits_reward, t.title
48
+ FROM claims c JOIN tasks t ON t.id = c.task_id
49
+ WHERE c.id = $1 FOR UPDATE`,
50
+ [claimId]
51
+ );
52
+ if (claimRows.length === 0) throw { code: 'CLAIM_NOT_FOUND' };
53
+ const claim = claimRows[0];
54
+ if (claim.builder_id !== builderId) throw { code: 'NOT_YOUR_CLAIM' };
55
+ if (claim.released_at) throw { code: 'ALREADY_RESOLVED' };
56
+ // Session lock: only the session that created this claim can resolve it.
57
+ // Skipped when force=true (emergency), when the claim pre-dates this
58
+ // migration (creator_session_id IS NULL), or when the resolver has no
59
+ // session context (legacy path). This closes the cross-session release hole.
60
+ if (!force && claim.creator_session_id && resolverSessionId &&
61
+ claim.creator_session_id !== resolverSessionId) {
62
+ throw { code: 'CLAIM_SESSION_MISMATCH', claimWorktree: claim.worktree_name ?? null };
63
+ }
64
+
65
+ await client.query(
66
+ `UPDATE claims SET released_at = now(), outcome = $2 WHERE id = $1`,
67
+ [claimId, outcome]
68
+ );
69
+
70
+ // Map outcome → task status under the new lifecycle.
71
+ // 'shipped' from the CLI now means "builder declared done" → completed.
72
+ // The CLI keeps the legacy outcome name to avoid an enum migration on
73
+ // claims.outcome; the task-level status is what's gaining the new states.
74
+ let newTaskStatus;
75
+ if (outcome === 'shipped') newTaskStatus = 'completed';
76
+ else if (outcome === 'abandoned') newTaskStatus = 'ready';
77
+ else if (outcome === 'partial') newTaskStatus = 'ready';
78
+ else newTaskStatus = 'ready';
79
+
80
+ await client.query(
81
+ `UPDATE tasks
82
+ SET status = $2,
83
+ value_summary = COALESCE($3, value_summary),
84
+ updated_at = now()
85
+ WHERE id = $1`,
86
+ [claim.task_id, newTaskStatus, valueSummary ?? null]
87
+ );
88
+
89
+ // touches[] cleanup [7/8] (task 882): backfill touches[] from the REAL
90
+ // committed diff at ship. The planning-time touches[] is now optional (a
91
+ // prediction nobody has to get right up front); on a successful ship we
92
+ // replace it with what git actually reports, so the declared set becomes
93
+ // reality. This is the write path that makes the touches[]-keyed profile
94
+ // counters (art_ships / pms_ships, see achievements + db leaderboard) and
95
+ // the historical record diff-accurate — closing the loop left open in #878.
96
+ // Only on a 'shipped' resolve with a non-empty list (never wipe touches[]
97
+ // with an empty diff on a DB-only / no-op ship).
98
+ if (outcome === 'shipped' && Array.isArray(committedFiles) && committedFiles.length > 0) {
99
+ const cleaned = Array.from(
100
+ new Set(committedFiles.map((p) => String(p || '').trim()).filter(Boolean))
101
+ );
102
+ if (cleaned.length > 0) {
103
+ await client.query(
104
+ `UPDATE tasks SET touches = $2, updated_at = now() WHERE id = $1`,
105
+ [claim.task_id, cleaned]
106
+ );
107
+ }
108
+ }
109
+
110
+ // Task #1048 (ADR 0046 follow-up): record the builder's sandbox self-approval
111
+ // as a durable fact on the task. When a dev-box builder reviews a staged
112
+ // game change on their sandbox and approves shipping it (TTY 'y', or an
113
+ // explicit --approved re-run on the dominant non-TTY path), ship.js threads
114
+ // { stamp, url, method } through this resolve call. We stamp `by` + `at`
115
+ // SERVER-SIDE (never trust the client for identity or time) and persist the
116
+ // blob to tasks.sandbox_review. Only on a 'shipped' resolve carrying a
117
+ // payload — laptop ships, non-game diffs, quick-tunnel boxes, --skip-stage,
118
+ // and un-approved re-runs send nothing, leaving the column NULL.
119
+ if (outcome === 'shipped' && sandboxReview && typeof sandboxReview === 'object' && sandboxReview.stamp) {
120
+ // Sanitize the client-supplied fields (defense in depth): the builder's
121
+ // box controls stamp/url/method, so clamp lengths, whitelist `method`, and
122
+ // accept only an https:// URL. This keeps a forged/oversized value — or a
123
+ // future stored-XSS string like a `javascript:` URL rendered in the hall —
124
+ // out of the persisted record. `by`/`at` stay SERVER-stamped (never
125
+ // client-set), so identity and time can't be spoofed.
126
+ const srStamp = String(sandboxReview.stamp).slice(0, 128);
127
+ const srUrl = (typeof sandboxReview.url === 'string' && /^https:\/\//.test(sandboxReview.url))
128
+ ? sandboxReview.url.slice(0, 256)
129
+ : null;
130
+ const srMethod = ['tty', 'flag'].includes(sandboxReview.method) ? sandboxReview.method : 'flag';
131
+ await client.query(
132
+ `UPDATE tasks
133
+ SET sandbox_review = jsonb_build_object(
134
+ 'by', (SELECT github_login FROM builders WHERE id = $2),
135
+ 'at', now(),
136
+ 'stamp', $3::text,
137
+ 'url', $4::text,
138
+ 'method', $5::text
139
+ ),
140
+ updated_at = now()
141
+ WHERE id = $1`,
142
+ [claim.task_id, builderId, srStamp, srUrl, srMethod]
143
+ );
144
+ }
145
+
146
+ // Write the session_logs entry + recompute actual_minutes FIRST, so the
147
+ // grader (below) can see a fresh actual_minutes value when scoring the
148
+ // est/actual ratio criterion.
149
+ await sealSessionLog(client, {
150
+ builderId, taskId: claim.task_id, claimId, startedAt: claim.claimed_at,
151
+ outcome, notesMd: notesMd ?? null,
152
+ });
153
+ await client.query(
154
+ `UPDATE tasks
155
+ SET actual_minutes = (
156
+ SELECT COALESCE(
157
+ SUM(EXTRACT(EPOCH FROM (ended_at - started_at)) / 60)::integer,
158
+ 0
159
+ )
160
+ FROM session_logs
161
+ WHERE task_id = $1
162
+ )
163
+ WHERE id = $1`,
164
+ [claim.task_id]
165
+ );
166
+
167
+ // V3.R18 (#209): the credits gate moved out of resolveClaim into a separate
168
+ // subagent grader. resolveClaim's job is now just "did local verification
169
+ // (scanner + smoke) pass — if yes, land at completed and stop." The caller
170
+ // then runs the subagent grader and POSTs the result to /tasks/:id/grade,
171
+ // which advances completed→confirmed and awards credits when the grader
172
+ // passes (see applyGrade below).
173
+ //
174
+ // The response shape preserves `advanceToMerge` and `grade` so ship.js
175
+ // doesn't need to change at the resolveClaim seam — both are always
176
+ // false/null with the new flow.
177
+
178
+ return {
179
+ taskId: claim.task_id,
180
+ taskTitle: claim.title,
181
+ taskStatus: newTaskStatus,
182
+ creditsAwarded: 0,
183
+ advanceToMerge: false,
184
+ justUnlocked: [],
185
+ grade: null,
186
+ };
187
+ });
188
+ }
189
+
190
+ // Final transition: confirmed → shipped. Called by /merge-mode (or auto-merge
191
+ // in /builder-ship) once the branch is on main + deployed. THIS is the credit
192
+ // moment — ADR 0120 pays on land, so awardLandingCredit books the reward here
193
+ // (credit_log.reason = 'task.shipped'), not at confirm. Sets shipped_at.
194
+ //
195
+ // V3.R14 (#238): also stamps builders.first_ship_at the first time this
196
+ // builder ever crosses the ship threshold. COALESCE makes the write
197
+ // idempotent — subsequent ships leave the first stamp untouched. Done in
198
+ // the same transaction so a roster query that joins both tables sees a
199
+ // consistent view.
200
+
201
+ // awardLandingCredit — the per-task reward, booked at the SHIPPED transition
202
+ // (ADR 0120 "pay on land"). Moved out of the three confirm paths (confirmTask /
203
+ // decideOverrideRequest / applyGrade): the per-task credit + idea-promotion
204
+ // bonus now exist ONLY once the code is merged to `main`, so a task that
205
+ // strands at 'confirmed' pays nothing. Idempotent on (task_id,
206
+ // reason='task.shipped') — mirrors the old (task_id, 'task.confirmed') guard —
207
+ // so a re-ship or a reconciler re-sweep is a true no-op. The reason is
208
+ // deliberately 'task.shipped' (not 'task.confirmed'): after the one-time
209
+ // clawback (ADR 0120 part 4) reverses a stranded task's OLD 'task.confirmed'
210
+ // row, re-landing books a FRESH 'task.shipped' row — the reward is fully
211
+ // re-earnable, exactly as the ADR promises. Caller passes its ship txn client
212
+ // so the credit commits atomically with the status flip.
213
+ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsReward, kind }) {
214
+ const reward = seams.resolveOptional('reward');
215
+ if (!reward || creditedBuilderId == null || !(Number(creditsReward) > 0)) {
216
+ return { creditsAwarded: 0, ideaBonus: null };
217
+ }
218
+ const { rows: existing } = await client.query(
219
+ `SELECT 1 FROM credit_log WHERE task_id = $1 AND reason = 'task.shipped' LIMIT 1`,
220
+ [taskId]
221
+ );
222
+ if (existing.length > 0) return { creditsAwarded: 0, ideaBonus: null };
223
+ const credit = await reward.insertMultipliedConfirmCredit(
224
+ client, creditedBuilderId, taskId, creditsReward, kind, 'task.shipped'
225
+ );
226
+ // Stream 1 of the ideator credit lane (ADR 0172 / IC4) REPLACES the ADR 0023
227
+ // 25% idea-promotion slice here — one idea-credit-on-ship path, not two
228
+ // (ADR 0172 §4). Unlike the slice (25% of the builder estimate, so zero under
229
+ // cost-plus-only), this pays 5×grade-factor on the IDEATOR lane, so an idea
230
+ // earns as its work ships even when per-task builder credit is withheld. It
231
+ // reads the one-idea-many-tasks lineage (ideas_task_lineage, IC3), so a Full
232
+ // Idea that became a whole goal pays on every one of its shipped tasks — the
233
+ // old slice only saw the single promoted_to_task_id. `maybeAwardIdeaPromotionBonus`
234
+ // stays on the port for its unit tests / any compat caller, but is no longer
235
+ // wired at ship. Guarded so a null return (lane off, no source idea) is fine.
236
+ const ideaBonus = reward.awardIdeaTaskShipCredit
237
+ ? await reward.awardIdeaTaskShipCredit(client, { taskId })
238
+ : null;
239
+ return {
240
+ creditsAwarded: credit.creditsAwarded,
241
+ creditsRaw: credit.raw,
242
+ creditsMultiplier: credit.multiplier,
243
+ ideaBonus,
244
+ };
245
+ }
246
+
247
+ // flagTaskNeedsRebase — ADR 0120 part 2. The publish-reconciler calls this when
248
+ // a confirmed task's land cannot be completed after the auto-land grace (its
249
+ // retries + the ADR 0082 generated-file self-heal): a persistently-red PR that
250
+ // hit the regen-heal cap, or a strand older than the stale threshold that can't
251
+ // be proven landed (never pushed / no recorded tip). It is a SIGNAL, not a
252
+ // status change — the task stays 'confirmed'; it is simply marked as awaiting
253
+ // its BUILDER's rebase (attribution via tasks.shipped_by = claim holder).
254
+ //
255
+ // Idempotent + self-limiting: only a still-'confirmed', not-already-flagged task
256
+ // is touched (WHERE status='confirmed' AND needs_rebase=false), so a task that
257
+ // has since shipped or been released is never (re-)flagged, and a re-sweep of an
258
+ // already-flagged task is a no-op. Returns true iff this call newly flagged it
259
+ // (so the reconciler logs the transition once). needs_rebase_at is set once
260
+ // (COALESCE) so the hall/CLI can show how long it has been outstanding.
261
+ async function flagTaskNeedsRebase(taskId, reason = null) {
262
+ const { rows } = await pool.query(
263
+ `UPDATE tasks
264
+ SET needs_rebase = true,
265
+ needs_rebase_reason = $2,
266
+ needs_rebase_at = COALESCE(needs_rebase_at, now()),
267
+ updated_at = now()
268
+ WHERE id = $1 AND status = 'confirmed' AND needs_rebase = false
269
+ RETURNING id`,
270
+ [taskId, reason]
271
+ );
272
+ return rows.length > 0;
273
+ }
274
+
275
+ // needsRebaseTasksForBuilder — the confirmed tasks a builder owes a rebase on
276
+ // (ADR 0120 parts 2-3). Attribution is tasks.shipped_by (= the claim holder,
277
+ // COALESCE'd there at confirm). Used by the claim gate (part 3) to refuse NEW
278
+ // claims and by the hall/CLI surfacing (part 5). Accepts an optional client so
279
+ // it can run inside claimTask's FOR UPDATE txn or standalone on the pool.
280
+ // Ordered oldest-flagged first so the surfacing can show the longest-outstanding.
281
+ async function needsRebaseTasksForBuilder(builderId, client = pool) {
282
+ const { rows } = await client.query(
283
+ `SELECT id, title, needs_rebase_reason, needs_rebase_at
284
+ FROM tasks
285
+ WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
286
+ ORDER BY needs_rebase_at ASC NULLS LAST, id ASC`,
287
+ [builderId]
288
+ );
289
+ return rows;
290
+ }
291
+
292
+ // The ship gate (task 1002483, ADR 0184) — see ./ship-assurance for the why.
293
+ const { normalizeShipAssurance, assertShipAssurance, recordShipAssurance, SHIP_ASSURANCE_KINDS } = require('./ship-assurance');
294
+
295
+ async function shipTask({ taskId, builderId, landAssurance }, deps = {}) {
296
+ const shipAssuranceValue = assertShipAssurance(landAssurance); // FAIL-CLOSED, ADR 0184
297
+ // #538 test seam (mirrors confirmTask): a caller-supplied pool is honored ONLY
298
+ // under NODE_ENV==='test', so a regression test can drive the txn with a
299
+ // SQL-aware fakeClient. Production callers omit deps → module pool.
300
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
301
+ const { shipRow, graduationBuilder, rankGraduation, actualShipperId, closedClaimCount, creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked } = await withTx(async (client) => {
302
+ const { rows } = await client.query(
303
+ `UPDATE tasks
304
+ SET status = 'shipped',
305
+ shipped_at = now(),
306
+ shipped_by = COALESCE(shipped_by, $2),
307
+ needs_rebase = false,
308
+ needs_rebase_reason = NULL,
309
+ needs_rebase_at = NULL,
310
+ updated_at = now()
311
+ WHERE id = $1 AND status = 'confirmed'
312
+ RETURNING id, title, status, shipped_at, shipped_by, security_sensitive, value_summary, credits_reward, kind`,
313
+ [taskId, builderId]
314
+ );
315
+ if (rows.length === 0) {
316
+ const e = new Error('task not in confirmed state');
317
+ e.code = 'TASK_NOT_CONFIRMED';
318
+ throw e;
319
+ }
320
+ // task 1643: close any claim still OPEN on this task, inside the same txn
321
+ // that shipped it. The normal path already closed the claim in resolveClaim
322
+ // (claimed→completed); this is the safety net for STRANDS — a task that
323
+ // reached a terminal state without that resolve (strand recovery, direct-SQL
324
+ // fixups, or the confirm paths, which flip status + award credits but never
325
+ // touch the claim). Without it the claim dangles released_at=NULL forever and
326
+ // every claim-aware view (builder dashboard, parallel-safety) keeps showing
327
+ // the shipped task as actively claimed — the BV1.R04/R05 strand class (live
328
+ // repro task 1300 / claim #1292). COALESCE preserves any existing outcome; an
329
+ // open claim has outcome=NULL so it lands 'shipped'. The stale-claim sweeper
330
+ // can't do this — its release resets the task to 'ready', un-shipping it.
331
+ // Invariant established here: a shipped task has no open claims.
332
+ const { rows: closedClaims } = await client.query(
333
+ `UPDATE claims
334
+ SET released_at = now(),
335
+ outcome = COALESCE(outcome, 'shipped')
336
+ WHERE task_id = $1 AND released_at IS NULL
337
+ RETURNING id`,
338
+ [taskId]
339
+ );
340
+ // shipped_by is what actually counts — COALESCE above means the row's
341
+ // historical shipped_by wins over the late-arriving builderId, so use
342
+ // the returned value to be sure we credit the right onboarding ship.
343
+ const actualShipperId = rows[0].shipped_by ?? builderId;
344
+ if (actualShipperId != null) {
345
+ await client.query(
346
+ `UPDATE builders
347
+ SET first_ship_at = COALESCE(first_ship_at, now())
348
+ WHERE id = $1`,
349
+ [actualShipperId]
350
+ );
351
+ }
352
+ // V3.R75 (#294) / #766: tick the onboarding state machine inside the same
353
+ // transaction that bumped first_ship_at. The terminal stage is now
354
+ // 'ship_three' — it clears only once the builder has THREE shipped tasks,
355
+ // the same threshold that auto-promotes a Xenos to Thetes below, so the
356
+ // checklist graduation coincides with the rank promotion. The 1st/2nd ships
357
+ // are deliberately no-ops for the stage (the hall's "N of 3" counter comes
358
+ // from the live shipped count, not from the stage). markStage is idempotent.
359
+ // We wrap in try/catch so the ship can't fail because of an onboarding
360
+ // plumbing error — best-effort, mirrors the safeMarkStage helper but inlined
361
+ // to share the transaction.
362
+ // The onboarding state machine carved into modules/onboarding/ (BV1.R81 /
363
+ // ADR 0093 §1): resolve the `onboarding` kernel port and pass this txn
364
+ // `client` — never a direct require of a module. ?. degrades to a no-op when
365
+ // the module is off (best-effort anyway).
366
+ let graduationBuilder = null;
367
+ if (actualShipperId != null) {
368
+ try {
369
+ const onboarding = seams.resolveOptional('onboarding');
370
+ const { rows: sc } = await client.query(
371
+ `SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1 AND status = 'shipped'`,
372
+ [actualShipperId]
373
+ );
374
+ const result = (onboarding && sc[0].n >= THETES_GRADUATION_THRESHOLD)
375
+ ? await onboarding.markStage(client, actualShipperId, 'ship_three')
376
+ : null;
377
+ if (result && result.justGraduated) {
378
+ // Read the builder row for the broadcast (display_name). Cheap —
379
+ // same transaction. Broadcast itself fires AFTER COMMIT so a chat
380
+ // failure can't roll back the ship.
381
+ const { rows: br } = await client.query(
382
+ `SELECT id, display_name, github_login FROM builders WHERE id = $1`,
383
+ [actualShipperId]
384
+ );
385
+ graduationBuilder = br[0] || null;
386
+ }
387
+ } catch (e) {
388
+ // Don't roll back the ship for an onboarding write failure.
389
+ console.log('[gds] shipTask onboarding markStage failed (non-blocking):', e && e.message);
390
+ }
391
+ }
392
+
393
+ // #692 / ADR 0034 (trigger #704): thetes RANK auto-graduation — distinct
394
+ // from the onboarding-stage graduation above. Keyed on the attributed
395
+ // shipper; if they're now a Xenos with 3 successfully SHIPPED tasks, promote
396
+ // xenos→thetes atomically with this ship. Rank-guarded + idempotent.
397
+ const rankGraduation = await maybeAutoGraduateToThetes(client, actualShipperId);
398
+
399
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
400
+ // booked HERE, at the confirmed→shipped flip — no longer at confirm. The
401
+ // credit only exists once the code is on `main`; a task that strands at
402
+ // 'confirmed' (never lands) now pays nothing. Attribution is the CLAIM
403
+ // HOLDER (the builder who did the work), computed the same way as
404
+ // confirmTask — never the caller who happened to trigger the ship (the
405
+ // reconciler / a supervisor). mostRecentClaimHolderId reads the latest
406
+ // claim by claimed_at regardless of released_at, so it is correct even
407
+ // though the claims for this task were just closed above. Idempotent on
408
+ // (task_id, reason='task.shipped'), so a re-ship / reconciler re-sweep is a
409
+ // true no-op. Runs in THIS txn so the credit commits atomically with the
410
+ // status flip.
411
+ const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? actualShipperId;
412
+ const landing = await awardLandingCredit(client, {
413
+ taskId,
414
+ creditedBuilderId,
415
+ creditsReward: rows[0].credits_reward,
416
+ kind: rows[0].kind,
417
+ });
418
+ // task 1679 pattern: state-based achievement reconcile now that the credit
419
+ // (above) + the ship count are reflected in this txn. SAVEPOINT-wrapped so
420
+ // a gamification error can never fail the ship. Moved to ship because the
421
+ // credit it keys off (breaking-the-100c-bar etc.) now lands here.
422
+ let justUnlocked = [];
423
+ const rewardPort = seams.resolveOptional('reward');
424
+ if (rewardPort && rewardPort.reconcileBuilderAchievements && creditedBuilderId != null) {
425
+ await client.query('SAVEPOINT ach_reconcile_ship');
426
+ try {
427
+ justUnlocked = await rewardPort.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
428
+ await client.query('RELEASE SAVEPOINT ach_reconcile_ship');
429
+ } catch (_) {
430
+ await client.query('ROLLBACK TO SAVEPOINT ach_reconcile_ship');
431
+ justUnlocked = [];
432
+ }
433
+ }
434
+ await doneWhen.closeCompletedWorkForShip(client, taskId); // task 1002069/ADR 0183 — in-txn criterion+goal close
435
+ return {
436
+ shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
437
+ closedClaimCount: closedClaims.length,
438
+ creditedBuilderId, creditsAwarded: landing.creditsAwarded, ideaBonus: landing.ideaBonus, justUnlocked,
439
+ };
440
+ }, { pool: activePool });
441
+ if (closedClaimCount > 0) {
442
+ // Post-commit observability: a normal ship closes 0 here (resolveClaim
443
+ // already released the claim). A non-zero count means this ship healed a
444
+ // strand — worth a log line so the class is visible if it recurs (task 1643).
445
+ console.log(`[gds] shipTask: closed ${closedClaimCount} orphaned claim(s) on shipped task ${taskId} (strand safety net — task 1643)`);
446
+ }
447
+ if (graduationBuilder) {
448
+ try {
449
+ seams.resolveOptional('onboarding')?.broadcastGraduation(graduationBuilder);
450
+ } catch (e) {
451
+ console.log('[gds] graduation broadcast post-ship failed:', e && e.message);
452
+ }
453
+ }
454
+ if (rankGraduation) {
455
+ await seams.emitAsync('builder.box-reconcile-needed', {
456
+ builderId: actualShipperId, rank: 'thetes', status: rankGraduation.status, actor: 'auto-graduation:#692',
457
+ }).catch(() => {});
458
+ }
459
+ await recordShipAssurance({ insertAuditLog, taskId, builderId: actualShipperId, assurance: shipAssuranceValue, log });
460
+ return {
461
+ ...shipRow,
462
+ shipAssurance: shipAssuranceValue, // echoed so a caller can show it, not guess
463
+
464
+ graduatedTo: rankGraduation ? 'thetes' : null,
465
+ // ADR 0120: the per-task reward that landed on THIS ship (0 on a re-ship /
466
+ // reconciler re-sweep — idempotent). The ship route surfaces it so the
467
+ // builder sees "credits landed" at ship, not at confirm.
468
+ creditedBuilderId, creditsAwarded, ideaBonus, justUnlocked,
469
+ };
470
+ }
471
+
472
+ // task 1134 — feed for the server-owned publish reconciler (src/bongos/
473
+ // publish-reconciler.js). Returns one row per CONFIRMED task from its LATEST claim
474
+ // (by claimed_at): builder_id (the claim holder, passed to shipTask so the ship is
475
+ // attributed to whoever did the work — shipped_by / first_ship_at / thetes
476
+ // graduation — exactly as a self-poll would), worktree_name (→ branch
477
+ // `claude/<worktree_name>`), and head_sha.
478
+ //
479
+ // task 1313 — TWO changes that close the strand class:
480
+ // 1) head_sha is now selected, so the reconciler can prove a land directly from
481
+ // the commit (isAncestorOfDefaultBranch) when the PR/branch is gone.
482
+ // 2) the `worktree_name IS NOT NULL` filter is GONE. A confirmed task whose
483
+ // latest claim carries no worktree_name (a server-mediated publish that never
484
+ // recorded one) used to be omitted entirely and strand forever; it now comes
485
+ // through so the head_sha ancestry check can heal it. Rows with neither a
486
+ // usable branch nor a sha-shaped head_sha are simply no-ops in the sweep.
487
+ // No schema change for head_sha: claims.worktree_name + head_sha + claimed_at all
488
+ // exist. task 1547 adds c.published_head_sha (the SERVER-pushed tip) so the sweep
489
+ // proves a per-task land from this task's OWN commit, not a (reusable) branch name.
490
+
491
+ async function listConfirmedTasksForReconcile({ limit = 100 } = {}) {
492
+ // t.updated_at is set to now() when confirmTask flips status→'confirmed', so for a
493
+ // STRANDED confirmed task (nothing else touches it) it is a faithful proxy for the
494
+ // confirm time. The reconciler ages out silent strands on it (task 1546 / blocker 47:
495
+ // 15 tasks sat unprovable for up to 66h because every sweep miss was a silent continue).
496
+ const { rows } = await pool.query(
497
+ `SELECT DISTINCT ON (t.id) t.id AS task_id, t.title, t.security_sensitive,
498
+ c.builder_id, c.worktree_name, c.head_sha,
499
+ c.published_head_sha, t.updated_at AS confirmed_at
500
+ FROM tasks t
501
+ JOIN claims c ON c.task_id = t.id
502
+ WHERE t.status = 'confirmed'
503
+ ORDER BY t.id, c.claimed_at DESC
504
+ LIMIT $1`,
505
+ [limit]
506
+ );
507
+ return rows;
508
+ }
509
+
510
+ // task 1547 — record the tip the server actually pushed for this task's
511
+ // server-mediated publish (ADR 0055), on the LATEST claim for the task. Called by
512
+ // POST /tasks/:id/publish-branch after a successful pushBundle. This is the durable
513
+ // per-task land proof the reconcile gates on: claims.head_sha is the claim-time
514
+ // BASE, which for a task whose base is already on main (e.g. an R03 base) would
515
+ // false-positive an ancestry check; the published tip cannot. Idempotent — a
516
+ // re-publish of the same branch just re-stamps the same sha. Returns the updated
517
+ // claim id, or null if no claim exists (defensive — publish-branch is ownership-gated).
518
+
519
+ async function setClaimPublishedSha({ taskId, builderId, sha }) {
520
+ if (!sha || !/^[0-9a-f]{7,64}$/.test(String(sha))) return null;
521
+ const { rows } = await pool.query(
522
+ `UPDATE claims
523
+ SET published_head_sha = $3
524
+ WHERE id = (
525
+ SELECT id FROM claims
526
+ WHERE task_id = $1 AND builder_id = $2
527
+ ORDER BY claimed_at DESC, id DESC
528
+ LIMIT 1
529
+ )
530
+ RETURNING id`,
531
+ [taskId, builderId, sha]
532
+ );
533
+ return rows[0]?.id ?? null;
534
+ }
535
+
536
+ // task 1547 — the published tip for the LATEST claim on a task (any builder), used
537
+ // by the publish-status reconcile-on-read to verify the task's own commit landed
538
+ // before flipping confirmed->shipped. NULL when no server-mediated publish recorded
539
+ // one (legacy rows / a publish that never succeeded — e.g. the BV1.R04/R05 strand).
540
+
541
+ async function getClaimPublishedSha(taskId) {
542
+ const { rows } = await pool.query(
543
+ `SELECT published_head_sha
544
+ FROM claims
545
+ WHERE task_id = $1
546
+ ORDER BY claimed_at DESC, id DESC
547
+ LIMIT 1`,
548
+ [taskId]
549
+ );
550
+ return rows[0]?.published_head_sha ?? null;
551
+ }
552
+
553
+ // V3.R14 (#238) — onboarding observability roster.
554
+ //
555
+ // One row per builder, joined with the live "what are they doing right
556
+ // now" signals the Archon needs to triage onboarding from /builders:
557
+ //
558
+ // - identity: id, github_login, display_name, rank, status
559
+ // - credits: total_credits (drachmae)
560
+ // - karma: karma (good-citizenship track)
561
+ // - onboarding: onboarded_at, first_ship_at,
562
+ // time_to_first_ship_minutes (NULL until they've shipped)
563
+ // - claims: active_claim_count, stale_claim
564
+ //
565
+ // "stale_claim" mirrors the stale-claim-release sweeper definition
566
+ // (.claude/scheduled-tasks/stale-claim-release/SKILL.md): a still-open
567
+ // claim where either the last session_pulses row is older than the
568
+ // stale threshold, or there's no pulse row at all AND claimed_at is
569
+ // older than the threshold. Default threshold is 24h to match this
570
+ // task's spec — the sweeper itself uses GDS_STALE_CLAIM_HOURS (default
571
+ // 6h) and is in charge of *acting*. The roster only *surfaces*.
572
+ //
573
+ // Returns: array of builder roster rows, ordered by:
574
+ // 1. inactive builders sink to the bottom (status='inactive' last)
575
+ // 2. still-onboarding (first_ship_at IS NULL) above shipped builders
576
+ // 3. then by created_at DESC inside each group (newest first)
577
+
578
+ async function getBuildersRoster({ staleHours = 24 } = {}) {
579
+ const hours = Number(staleHours);
580
+ if (!Number.isFinite(hours) || hours <= 0) {
581
+ throw new Error('getBuildersRoster: staleHours must be a positive number');
582
+ }
583
+ const { rows } = await pool.query(
584
+ `WITH stale_active AS (
585
+ SELECT c.builder_id,
586
+ COUNT(*) FILTER (
587
+ WHERE ${staleClaimHourPredicate('$1')}
588
+ ) AS stale_claim_count,
589
+ COUNT(*) AS active_claim_count
590
+ FROM claims c
591
+ LEFT JOIN session_pulses sp ON sp.claim_id = c.id
592
+ WHERE c.released_at IS NULL
593
+ GROUP BY c.builder_id
594
+ )
595
+ SELECT
596
+ b.id,
597
+ b.github_login,
598
+ b.display_name,
599
+ b.avatar_url,
600
+ b.rank,
601
+ b.status,
602
+ b.total_credits,
603
+ b.karma,
604
+ b.created_at,
605
+ b.onboarded_at,
606
+ b.first_ship_at,
607
+ CASE
608
+ WHEN b.first_ship_at IS NULL OR b.onboarded_at IS NULL THEN NULL
609
+ ELSE GREATEST(
610
+ 0,
611
+ EXTRACT(EPOCH FROM (b.first_ship_at - b.onboarded_at))::bigint / 60
612
+ )
613
+ END AS time_to_first_ship_minutes,
614
+ COALESCE(sa.active_claim_count, 0) AS active_claim_count,
615
+ (COALESCE(sa.stale_claim_count, 0) > 0) AS stale_claim
616
+ FROM builders b
617
+ LEFT JOIN stale_active sa ON sa.builder_id = b.id
618
+ ORDER BY
619
+ (b.status = 'inactive') ASC,
620
+ (b.first_ship_at IS NOT NULL) ASC,
621
+ b.created_at DESC`,
622
+ [hours]
623
+ );
624
+ return rows;
625
+ }
626
+
627
+ // V3.R14 (#238) — stale claim detail for the roster expand view.
628
+ // Returns the active claim rows for a specific builder *with* stale
629
+ // signals, so the Archon can pick the right one to release. Used by
630
+ // the management panel's per-row "release stale claim" action.
631
+
632
+ async function getActiveClaimsForBuilderWithStale(builderId, { staleHours = 24 } = {}) {
633
+ const builder = Number(builderId);
634
+ const hours = Number(staleHours);
635
+ if (!Number.isFinite(builder)) {
636
+ throw new Error('getActiveClaimsForBuilderWithStale: builderId must be numeric');
637
+ }
638
+ if (!Number.isFinite(hours) || hours <= 0) {
639
+ throw new Error('getActiveClaimsForBuilderWithStale: staleHours must be a positive number');
640
+ }
641
+ const { rows } = await pool.query(
642
+ `SELECT c.id AS claim_id,
643
+ c.task_id AS task_id,
644
+ c.builder_id AS builder_id,
645
+ c.claimed_at AS claimed_at,
646
+ c.worktree_name AS worktree_name,
647
+ t.title AS task_title,
648
+ sp.last_pulse_at AS last_pulse_at,
649
+ (
650
+ ${staleClaimHourPredicate('$2')}
651
+ ) AS stale_claim
652
+ FROM claims c
653
+ JOIN tasks t ON t.id = c.task_id
654
+ LEFT JOIN session_pulses sp ON sp.claim_id = c.id
655
+ WHERE c.released_at IS NULL
656
+ AND c.builder_id = $1
657
+ ORDER BY c.claimed_at ASC`,
658
+ [builder, hours]
659
+ );
660
+ return rows;
661
+ }
662
+
663
+ // V3.R14 (#238) — release a claim "on behalf of" the original builder.
664
+ // Archon-only at the route layer; this helper does no rank check itself,
665
+ // so callers MUST gate it.
666
+ //
667
+ // Mirrors the stale-claim-release sweeper's transaction shape:
668
+ // 1. lock the claim row, skip if it raced to released
669
+ // 2. set released_at + outcome='abandoned'
670
+ // 3. send the task back to status='ready'
671
+ // 4. write a session_logs entry tagged "[archon-release] ..." so the
672
+ // audit trail makes it obvious this was a third-party intervention
673
+ // and not the original builder abandoning their own work
674
+ //
675
+ // reason: free-text the Archon supplies (≤512 chars; the route validates).
676
+ // archonLogin: display string of the acting Archon, embedded in notes_md.
677
+
678
+ async function releaseClaimOnBehalf({ claimId, archonId, archonLogin, reason }) {
679
+ const id = Number(claimId);
680
+ if (!Number.isFinite(id)) {
681
+ throw Object.assign(new Error('claimId must be numeric'), { code: 'BAD_CLAIM_ID' });
682
+ }
683
+ return withTx(async (client) => {
684
+ const { rows: cur } = await client.query(
685
+ `SELECT c.id, c.task_id, c.builder_id, c.claimed_at, c.released_at,
686
+ t.title
687
+ FROM claims c JOIN tasks t ON t.id = c.task_id
688
+ WHERE c.id = $1
689
+ FOR UPDATE`,
690
+ [id]
691
+ );
692
+ if (cur.length === 0) {
693
+ throw Object.assign(new Error('claim not found'), { code: 'CLAIM_NOT_FOUND' });
694
+ }
695
+ if (cur[0].released_at) {
696
+ throw Object.assign(new Error('claim already released'), { code: 'ALREADY_RESOLVED' });
697
+ }
698
+ const { task_id: taskId, builder_id: ownerId, claimed_at: claimedAt, title } = cur[0];
699
+ const tag = '[archon-release]';
700
+ const actor = archonLogin
701
+ ? `@${archonLogin} (builder #${archonId})`
702
+ : `archon #${archonId}`;
703
+ const reasonText = (typeof reason === 'string' && reason.trim().length > 0)
704
+ ? reason.trim()
705
+ : 'no reason given';
706
+ const notesMd =
707
+ `${tag} released by ${actor}: ${reasonText} ` +
708
+ `Original claim by builder #${ownerId} for task #${taskId} "${title}".`;
709
+
710
+ await client.query(
711
+ `UPDATE claims SET released_at = now(), outcome = 'abandoned' WHERE id = $1`,
712
+ [id]
713
+ );
714
+ await client.query(
715
+ `UPDATE tasks
716
+ SET status = 'ready',
717
+ needs_rebase = false,
718
+ needs_rebase_reason = NULL,
719
+ needs_rebase_at = NULL,
720
+ updated_at = now()
721
+ WHERE id = $1`,
722
+ [taskId]
723
+ );
724
+ // session_logs row carries the original builder_id (whose session this
725
+ // was) PLUS the tagged notes_md identifying the acting Archon. The
726
+ // sweeper writes the same way — "the session belongs to the builder,
727
+ // the release was decided by someone else."
728
+ await sealSessionLog(client, {
729
+ builderId: ownerId, taskId, claimId: id, startedAt: claimedAt,
730
+ outcome: 'abandoned', notesMd,
731
+ });
732
+ return {
733
+ claim_id: id,
734
+ task_id: taskId,
735
+ original_builder_id: ownerId,
736
+ task_title: title,
737
+ notes_md: notesMd,
738
+ };
739
+ });
740
+ }
741
+
742
+ // Manual confirm: completed → confirmed without going through resolveClaim
743
+ // again. Awards credits if the task carries a reward and credits haven't
744
+ // already been awarded. For human override of failed auto-verification.
745
+
746
+ async function confirmTask({ taskId, builderId }, deps = {}) {
747
+ // #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
748
+ // so a regression test can drive the txn with a SQL-aware fakeClient (same
749
+ // pattern as decideOverrideRequest / override_attribution.mjs). Production
750
+ // callers omit deps → module pool.
751
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
752
+ return withTx(async (client) => {
753
+ // #661: credit the builder who actually DID the work — the task's claim
754
+ // holder — never the confirming Archon. The /tasks/:id/confirm route is
755
+ // archon-only and passes req.builder.id (the confirmer); before this fix
756
+ // both the credit_log row AND tasks.shipped_by were attributed to that
757
+ // Archon when they manual-confirmed someone else's completed task (live
758
+ // repro 2026-06-04: Xenos's #660 credit landed on builder #1). The claim
759
+ // holder is the builder on the most-recent claims row for the task — for a
760
+ // 'completed' task that is the builder whose 'shipped' resolve drove it
761
+ // there. Fall back to the confirmer only if the task has no claim at all (a
762
+ // degenerate state, e.g. status set directly via SQL) so a legitimate
763
+ // confirm is never refused. Mirrors the #538 fix on the override path.
764
+ const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
765
+
766
+ const { rows } = await client.query(
767
+ `UPDATE tasks
768
+ SET status = 'confirmed',
769
+ shipped_by = COALESCE(shipped_by, $2),
770
+ updated_at = now()
771
+ WHERE id = $1 AND status = 'completed'
772
+ RETURNING id, title, credits_reward, kind`,
773
+ [taskId, creditedBuilderId]
774
+ );
775
+ if (rows.length === 0) {
776
+ throw Object.assign(new Error('task not in completed state'), { code: 'TASK_NOT_COMPLETED' });
777
+ }
778
+ const task = rows[0];
779
+
780
+ // Credit allocation carved to modules/economy/ (BV1.R77 / ADR 0093 §1-2):
781
+ // resolve the `reward` kernel port once; every credit/achievement write below
782
+ // runs inside this same txn. resolveOptional → undefined on a vanilla instance
783
+ // without economy, so every use is guarded (degrades to a best-effort no-op).
784
+ const reward = seams.resolveOptional('reward');
785
+
786
+ // ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are NO
787
+ // LONGER awarded at confirm. They land at the confirmed→shipped flip
788
+ // (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task that
789
+ // strands at 'confirmed' pays nothing until its code is on `main`. confirm
790
+ // still flips the status and attributes shipped_by to the claim holder
791
+ // (above); the reward waits for the land. creditsAwarded/ideaBonus stay in
792
+ // the return shape (0 / null at confirm) so callers/tests keep a stable
793
+ // contract.
794
+ const creditsAwarded = 0;
795
+ const ideaBonus = null;
796
+
797
+ // task 1679: confirmTask is the --skip-grade / grader_bypass ship path — the
798
+ // DOMINANT one — and until now it awarded credits but NEVER evaluated
799
+ // achievements (only applyGrade did), so builders shipping with grader_bypass
800
+ // on only ever unlocked first-claim. Reconcile the full state-based set here
801
+ // so every ship path unlocks achievements. The claim is already
802
+ // outcome='shipped' (resolveClaim ran first) and credits (above + the session
803
+ // reward) are reflected in builders.total_credits, so all predicates see
804
+ // current state. Wrapped in a SAVEPOINT: achievements are best-effort
805
+ // gamification and must never fail a confirm — a reconcile error rolls back
806
+ // only the reconcile, leaving the confirm + credit intact.
807
+ let justUnlocked = [];
808
+ if (reward && reward.reconcileBuilderAchievements) {
809
+ await client.query('SAVEPOINT ach_reconcile');
810
+ try {
811
+ justUnlocked = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
812
+ await client.query('RELEASE SAVEPOINT ach_reconcile');
813
+ } catch (_) {
814
+ await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
815
+ justUnlocked = [];
816
+ }
817
+ }
818
+
819
+ // #704: thetes graduation is NOT evaluated here — it fires at SHIP time
820
+ // (shipTask), keyed on 3 successfully SHIPPED tasks, not at confirm.
821
+
822
+ return { taskId, taskTitle: task.title, taskStatus: 'confirmed', creditsAwarded, ideaBonus, creditedBuilderId, justUnlocked };
823
+ }, { pool: activePool });
824
+ }
825
+
826
+ // abandonTask (task 987) — the terminal "retire this task" transition. The GDS
827
+ // lifecycle had promote (backlog→ready), the claim-resolve path (active→
828
+ // completed/ready), and confirm/ship (completed→confirmed→shipped), but NO
829
+ // first-class way to KILL a task that is no longer relevant. The only routes to
830
+ // status='abandoned' were offboarding a builder (offboardBuilder, which seals
831
+ // each open claim) and a raw SQL UPDATE — so stale/moot tasks sat in 'ready' as
832
+ // claimable noise (the box-teardown chores #819/#822/#827 and the dup tasks
833
+ // #907/#926 that other sessions had already shipped, all retired 2026-06-13).
834
+ // This is the missing transition.
835
+ //
836
+ // Guards:
837
+ // - already 'abandoned' → idempotent no-op (alreadyAbandoned:true) so a retry
838
+ // or a double-click never errors.
839
+ // - 'shipped' → CANNOT_ABANDON_SHIPPED. Shipped work is immutable
840
+ // history; retiring it would corrupt the ledger and
841
+ // the version_progress rollups.
842
+ // - an unreleased claim → TASK_HAS_ACTIVE_CLAIM. A live claim means a session
843
+ // still owns the work; release it first so the abandon
844
+ // can never race a ship in flight.
845
+ // Any other status (backlog/ready/blocked/completed/confirmed) abandons. The
846
+ // route is metic+archon-gated (ADR 0152; archon-only until then), and the abandon
847
+ // + its reason are captured in audit_log by the writing middleware, so the
848
+ // append-only ledger records who retired what. restoreTask (below) is the undo.
849
+ //
850
+ // NOTE on dependencies: abandoning a task that is listed in another task's
851
+ // dependencies[] does NOT auto-adjust those dependents — they will simply never
852
+ // auto-promote on this (now dead) dep. That is intentionally left to the Archon
853
+ // (PUT/DELETE /tasks/:id/dependencies) rather than guessed at here; the stale
854
+ // chores this was built for are dependency leaves.
855
+ //
856
+ // #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
857
+ // so a regression test can drive the txn with a SQL-aware fakeClient, same as
858
+ // confirmTask / decideOverrideRequest. Production callers omit deps → module pool.
859
+
860
+ async function abandonTask({ taskId, reason }, deps = {}) {
861
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
862
+ const client = await activePool.connect();
863
+ try {
864
+ await client.query('BEGIN');
865
+
866
+ const { rows: taskRows } = await client.query(
867
+ `SELECT id, title, status FROM tasks WHERE id = $1 FOR UPDATE`,
868
+ [taskId]
869
+ );
870
+ if (taskRows.length === 0) {
871
+ throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
872
+ }
873
+ const task = taskRows[0];
874
+
875
+ if (task.status === 'abandoned') {
876
+ await client.query('COMMIT');
877
+ return { taskId, taskTitle: task.title, taskStatus: 'abandoned', previousStatus: 'abandoned', alreadyAbandoned: true };
878
+ }
879
+ if (task.status === 'shipped') {
880
+ throw Object.assign(new Error('cannot abandon a shipped task'), { code: 'CANNOT_ABANDON_SHIPPED' });
881
+ }
882
+
883
+ const { rows: activeClaims } = await client.query(
884
+ `SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`,
885
+ [taskId]
886
+ );
887
+ if (activeClaims.length > 0) {
888
+ throw Object.assign(new Error('task has an active claim; release it first'), { code: 'TASK_HAS_ACTIVE_CLAIM' });
889
+ }
890
+
891
+ const { rows: updated } = await client.query(
892
+ `UPDATE tasks
893
+ SET status = 'abandoned',
894
+ value_summary = COALESCE(NULLIF($2, ''), value_summary),
895
+ updated_at = now()
896
+ WHERE id = $1
897
+ RETURNING id, title, status`,
898
+ [taskId, reason ? `Abandoned: ${reason}` : '']
899
+ );
900
+
901
+ await client.query('COMMIT');
902
+ return {
903
+ taskId,
904
+ taskTitle: updated[0].title,
905
+ taskStatus: 'abandoned',
906
+ previousStatus: task.status,
907
+ alreadyAbandoned: false,
908
+ };
909
+ } catch (err) {
910
+ await client.query('ROLLBACK').catch(() => {});
911
+ throw err;
912
+ } finally {
913
+ client.release();
914
+ }
915
+ }
916
+
917
+ // restoreTask (ADR 0152) — the inverse of abandonTask: 'abandoned' → 'backlog'.
918
+ // Added when abandon was re-ranked Archon→Metic+, because until then abandon was
919
+ // one-way through the API (promote took only backlog/blocked, PATCH refuses
920
+ // `status`), so a mis-retire could only be undone with hand-SQL on prod. Handing a
921
+ // Metic the kill switch without an undo at the same rank would have moved the
922
+ // owner-cleanup burden rather than removed it.
923
+ //
924
+ // Restores to **backlog**, not ready: the task was retired deliberately, so it
925
+ // comes back un-workable and needs a second, explicit promote to be claimable.
926
+ // abandonTask stamps `value_summary = 'Abandoned: <reason>'`; a restored task
927
+ // clears exactly that stamp (and nothing else) so a live task never carries a
928
+ // retire note into its ship card / broadcast.
929
+ //
930
+ // Guards: TASK_NOT_FOUND, and TASK_NOT_ABANDONED for any other status (the route
931
+ // checks first, but the db guard makes a direct caller — a CLI, a poller — safe
932
+ // too). Reached via POST /tasks/:id/promote; the audit_log row is written by the
933
+ // writing middleware there, same as the abandon it reverses.
934
+ //
935
+ // #538 test seam: a caller-supplied pool is honored ONLY under NODE_ENV==='test'
936
+ // (matches abandonTask/confirmTask). Production callers omit deps → module pool.
937
+ async function restoreTask({ taskId }, deps = {}) {
938
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
939
+ const client = await activePool.connect();
940
+ try {
941
+ await client.query('BEGIN');
942
+
943
+ const { rows: taskRows } = await client.query(
944
+ `SELECT id, title, status FROM tasks WHERE id = $1 FOR UPDATE`,
945
+ [taskId]
946
+ );
947
+ if (taskRows.length === 0) {
948
+ throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
949
+ }
950
+ const task = taskRows[0];
951
+ if (task.status !== 'abandoned') {
952
+ throw Object.assign(new Error('task is not abandoned'), { code: 'TASK_NOT_ABANDONED' });
953
+ }
954
+
955
+ const { rows: updated } = await client.query(
956
+ `UPDATE tasks
957
+ SET status = 'backlog',
958
+ value_summary = CASE WHEN value_summary LIKE 'Abandoned: %' THEN NULL ELSE value_summary END,
959
+ updated_at = now()
960
+ WHERE id = $1
961
+ RETURNING id, title, status`,
962
+ [taskId]
963
+ );
964
+
965
+ await client.query('COMMIT');
966
+ return {
967
+ taskId,
968
+ taskTitle: updated[0].title,
969
+ taskStatus: 'backlog',
970
+ previousStatus: 'abandoned',
971
+ };
972
+ } catch (err) {
973
+ await client.query('ROLLBACK').catch(() => {});
974
+ throw err;
975
+ } finally {
976
+ client.release();
977
+ }
978
+ }
979
+
980
+ module.exports = {
981
+ abandonTask,
982
+ confirmTask,
983
+ flagTaskNeedsRebase,
984
+ getActiveClaimsForBuilderWithStale,
985
+ getBuildersRoster,
986
+ getClaimPublishedSha,
987
+ listConfirmedTasksForReconcile,
988
+ needsRebaseTasksForBuilder,
989
+ releaseClaimOnBehalf,
990
+ resolveClaim,
991
+ restoreTask,
992
+ setClaimPublishedSha,
993
+ shipTask,
994
+ };