@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,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
|
+
};
|