lockage 0.0.0-stage → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +355 -2
  3. package/dist/db/index.d.ts +52 -0
  4. package/dist/db/index.js +238 -0
  5. package/dist/db/schema.sql +238 -0
  6. package/dist/hook/clients.d.ts +56 -0
  7. package/dist/hook/clients.js +258 -0
  8. package/dist/hook/context.d.ts +22 -0
  9. package/dist/hook/context.js +40 -0
  10. package/dist/hook/dispatch.d.ts +21 -0
  11. package/dist/hook/dispatch.js +212 -0
  12. package/dist/hook/guard.d.ts +34 -0
  13. package/dist/hook/guard.js +106 -0
  14. package/dist/hook/repo.d.ts +40 -0
  15. package/dist/hook/repo.js +173 -0
  16. package/dist/hook/setup-kiro.d.ts +30 -0
  17. package/dist/hook/setup-kiro.js +118 -0
  18. package/dist/index.d.ts +47 -0
  19. package/dist/index.js +694 -0
  20. package/dist/launcher/client.d.ts +22 -0
  21. package/dist/launcher/client.js +57 -0
  22. package/dist/launcher/publish-adapter.d.ts +88 -0
  23. package/dist/launcher/publish-adapter.js +233 -0
  24. package/dist/launcher/publish-cli.d.ts +2 -0
  25. package/dist/launcher/publish-cli.js +123 -0
  26. package/dist/launcher/publish.d.ts +140 -0
  27. package/dist/launcher/publish.js +522 -0
  28. package/dist/lockage/board.d.ts +247 -0
  29. package/dist/lockage/board.js +407 -0
  30. package/dist/lockage/claim.d.ts +307 -0
  31. package/dist/lockage/claim.js +580 -0
  32. package/dist/lockage/files.d.ts +229 -0
  33. package/dist/lockage/files.js +403 -0
  34. package/dist/lockage/git.d.ts +92 -0
  35. package/dist/lockage/git.js +183 -0
  36. package/dist/lockage/globs.d.ts +26 -0
  37. package/dist/lockage/globs.js +125 -0
  38. package/dist/lockage/merge.d.ts +132 -0
  39. package/dist/lockage/merge.js +831 -0
  40. package/dist/lockage/publish-lock.d.ts +38 -0
  41. package/dist/lockage/publish-lock.js +73 -0
  42. package/dist/lockage/tickets.d.ts +213 -0
  43. package/dist/lockage/tickets.js +340 -0
  44. package/dist/lockage/worktree.d.ts +212 -0
  45. package/dist/lockage/worktree.js +563 -0
  46. package/package.json +43 -4
@@ -0,0 +1,580 @@
1
+ import { ulid } from 'ulid';
2
+ import { unixNow } from '../db/index.js';
3
+ // Task lease (the grace period before heartbeat lapse returns it to open). Distinct from claims' lease_ttl.
4
+ export const TASK_LEASE_SEC = 15 * 60; // 15 minutes
5
+ export function createTask(cdb, args) {
6
+ const dependsOn = args.dependsOn ?? [];
7
+ // If there is an existing cycle among depends_on, reject before adding edges.
8
+ // Invariant: the new task T has no edges yet, so "a cycle forms after adding T" happens only if
9
+ // there is an existing path D_i → … → D_j among depends_on and D_j also blocks T (= D_j is also in
10
+ // depends_on). Therefore it suffices to check, for every ordered pair (d, other) of depends_on,
11
+ // only "whether there is an existing path from other to d" (no need to traverse T itself).
12
+ for (const d of dependsOn) {
13
+ for (const other of dependsOn) {
14
+ if (d === other)
15
+ continue;
16
+ // Reachable from d to other and other also tries to block d → the seed of a cycle
17
+ if (isReachable(cdb.db, other, d)) {
18
+ return {
19
+ ok: false,
20
+ reason: 'cycle',
21
+ detail: `depends_on would create a dependency cycle (${other} ⇄ ${d})`,
22
+ };
23
+ }
24
+ }
25
+ }
26
+ const now = unixNow();
27
+ const id = ulid();
28
+ const ticketId = args.ticketId ?? null;
29
+ // Fix option A (F2 re-arm): when adding a task under a ticket, if the parent ticket already emitted
30
+ // ready (ticket_ready_at IS NOT NULL), reset ticket_ready_at to NULL to make ticket_ready re-emit.
31
+ // A task created by createTask is always open (unfinished), so the trigger condition is only "the
32
+ // ticket already emitted ready". The task INSERT and the ticket re-arm are atomized in the same
33
+ // BEGIN IMMEDIATE transaction (so consistency does not break if only one succeeds). The reverse CAS
34
+ // symmetric to tickets.ts's rearmTicketReady is written directly here to unify the transaction
35
+ // boundary (importing claim.ts→tickets.ts would not be a cycle, but a separate transaction would break atomicity).
36
+ const db = cdb.db;
37
+ db.exec('BEGIN IMMEDIATE');
38
+ try {
39
+ db.prepare(`INSERT INTO tasks (id, ticket_id, repo, title, description, status, assignee,
40
+ priority, version, heartbeat_at, created_at, updated_at, host_id, origin)
41
+ VALUES (?, ?, ?, ?, ?, 'open', NULL, ?, 0, NULL, ?, ?, ?, 'local')`).run(id, ticketId, args.repo, args.title, args.description ?? null, args.priority ?? 0, now, now, cdb.hostId);
42
+ if (ticketId !== null) {
43
+ db.prepare(`UPDATE tickets SET ticket_ready_at = NULL, updated_at = ?
44
+ WHERE id = ? AND ticket_ready_at IS NOT NULL`).run(now, ticketId);
45
+ }
46
+ // For each depends_on D, add D→T (D blocks T). T is new, so no cycle occurs.
47
+ for (const d of dependsOn) {
48
+ db.prepare(`INSERT OR IGNORE INTO task_edges (from_task, to_task, kind, host_id, origin)
49
+ VALUES (?, ?, 'blocks', ?, 'local')`).run(d, id, cdb.hostId);
50
+ }
51
+ db.exec('COMMIT');
52
+ }
53
+ catch (e) {
54
+ try {
55
+ db.exec('ROLLBACK');
56
+ }
57
+ catch {
58
+ /* already closed */
59
+ }
60
+ throw e;
61
+ }
62
+ return getTask(cdb.db, id);
63
+ }
64
+ /** Traverse task_edges (blocks) in the from→to direction and DFS-decide whether to is reachable from from. */
65
+ function isReachable(db, from, to) {
66
+ if (from === to)
67
+ return true;
68
+ const seen = new Set();
69
+ const stack = [from];
70
+ while (stack.length > 0) {
71
+ const cur = stack.pop();
72
+ if (cur === to)
73
+ return true;
74
+ if (seen.has(cur))
75
+ continue;
76
+ seen.add(cur);
77
+ const next = db
78
+ .prepare(`SELECT to_task FROM task_edges WHERE from_task = ? AND kind = 'blocks'`)
79
+ .all(cur);
80
+ for (const n of next)
81
+ stack.push(n.to_task);
82
+ }
83
+ return false;
84
+ }
85
+ /** Add one blocks edge (from blocks to). Ignore duplicates (idempotent via PK). */
86
+ function insertEdge(cdb, fromTask, toTask) {
87
+ cdb.db
88
+ .prepare(`INSERT OR IGNORE INTO task_edges (from_task, to_task, kind, host_id, origin)
89
+ VALUES (?, ?, 'blocks', ?, 'local')`)
90
+ .run(fromTask, toTask, cdb.hostId);
91
+ }
92
+ /**
93
+ * Add a dependency (blocks) edge between existing tasks: fromTask blocks toTask
94
+ * (= toTask waits for fromTask to finish). The after-the-fact version of create_task(depends_on).
95
+ * It cycle-checks before adding the edge and rejects with reason:'cycle' if it would form a cycle (§4.8 DAG invariant).
96
+ */
97
+ export function addDependency(cdb, fromTask, toTask) {
98
+ if (fromTask === toTask) {
99
+ return { ok: false, reason: 'self', detail: 'a task cannot block itself' };
100
+ }
101
+ const db = cdb.db;
102
+ const exists = (id) => !!db.prepare(`SELECT 1 FROM tasks WHERE id = ?`).get(id);
103
+ if (!exists(fromTask) || !exists(toTask)) {
104
+ return { ok: false, reason: 'not_found', detail: 'from_task or to_task does not exist' };
105
+ }
106
+ // Adding from→to forms a cycle if to is already reachable from from.
107
+ if (isReachable(db, toTask, fromTask)) {
108
+ return {
109
+ ok: false,
110
+ reason: 'cycle',
111
+ detail: `edge ${fromTask}→${toTask} would create a dependency cycle`,
112
+ };
113
+ }
114
+ insertEdge(cdb, fromTask, toTask);
115
+ return { ok: true };
116
+ }
117
+ /** Fetch one (undefined if it does not exist). */
118
+ export function getTask(db, id) {
119
+ return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id);
120
+ }
121
+ export function listTasks(cdb, args = {}) {
122
+ const db = cdb.db;
123
+ if (args.status === 'blocked') {
124
+ const rows = listBlockedTasks(cdb, {
125
+ repo: args.repo,
126
+ assignee: args.assignee,
127
+ ticketId: args.ticketId,
128
+ order: 'priority',
129
+ });
130
+ return args.readyOnly ? rows.filter((t) => taskIsReady(db, t.id)) : rows;
131
+ }
132
+ const clauses = [];
133
+ const params = [];
134
+ if (args.status) {
135
+ clauses.push('status = ?');
136
+ params.push(args.status);
137
+ }
138
+ if (args.assignee) {
139
+ clauses.push('assignee = ?');
140
+ params.push(args.assignee);
141
+ }
142
+ if (args.ticketId) {
143
+ clauses.push('ticket_id = ?');
144
+ params.push(args.ticketId);
145
+ }
146
+ if (args.repo) {
147
+ clauses.push('repo = ?');
148
+ params.push(args.repo);
149
+ }
150
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
151
+ const rows = db
152
+ .prepare(`SELECT * FROM tasks ${where} ORDER BY priority DESC, created_at ASC`)
153
+ .all(...params);
154
+ // ready_only: exclude tasks that have any unfinished blocker.
155
+ return args.readyOnly ? rows.filter((t) => taskIsReady(db, t.id)) : rows;
156
+ }
157
+ /** Whether all from_task blockers (blocks edges) are done. Same meaning as board.isReady. */
158
+ function taskIsReady(db, taskId) {
159
+ const blockers = db
160
+ .prepare(`SELECT t.status AS status
161
+ FROM task_edges e JOIN tasks t ON t.id = e.from_task
162
+ WHERE e.to_task = ? AND e.kind = 'blocks'`)
163
+ .all(taskId);
164
+ return blockers.every((b) => b.status === 'done');
165
+ }
166
+ /**
167
+ * §4.1 atomic claim — SQLite CAS.
168
+ * Take an immediate write lock with BEGIN IMMEDIATE, put all current-value conditions in WHERE, and
169
+ * treat changes==0 as a conflict. expected_version also detects the "someone touched it between read
170
+ * and claim" conflict.
171
+ */
172
+ export function claimTask(cdb, taskId, agentId, expectedVersion) {
173
+ const db = cdb.db;
174
+ const now = unixNow();
175
+ db.exec('BEGIN IMMEDIATE');
176
+ try {
177
+ const info = db
178
+ .prepare(`UPDATE tasks
179
+ SET assignee = ?,
180
+ status = 'in_progress',
181
+ version = version + 1,
182
+ heartbeat_at = ?,
183
+ updated_at = ?
184
+ WHERE id = ?
185
+ AND assignee IS NULL
186
+ AND status = 'open'
187
+ AND version = ?`)
188
+ .run(agentId, now, now, taskId, expectedVersion);
189
+ if (info.changes === 0) {
190
+ // Conflict or absence. Read the current value to distinguish the reason (read in the same transaction).
191
+ const current = db
192
+ .prepare('SELECT assignee, status, version FROM tasks WHERE id = ?')
193
+ .get(taskId);
194
+ db.exec('ROLLBACK');
195
+ if (!current)
196
+ return { ok: false, reason: 'not_found' };
197
+ return { ok: false, reason: 'conflict', current };
198
+ }
199
+ const task = getTask(db, taskId);
200
+ // 1A: when any task under it is claimed, auto-transition the parent ticket open→in_progress.
201
+ // Only when ticket_id is non-null (a minor task = ticket_id null has no ticket, so do nothing).
202
+ // For atomicity, bundle the claim success (tasks CAS) and the ticket transition in the same
203
+ // BEGIN IMMEDIATE transaction. With the same policy as createTask ("write the tasks INSERT +
204
+ // ticket re-arm SQL directly within the transaction boundary"), write here the same local CAS as
205
+ // tickets.ts's markTicketInProgressOnClaim (importing claim.ts→tickets.ts would not be a cycle,
206
+ // but a separate transaction would break the atomicity of claim success and the ticket transition).
207
+ // A one-way CAS that does not bump the version (WHERE status='open'): a ticket already
208
+ // in_progress/closed is untouched (idempotent = spec 2A's "do not automatically return to open").
209
+ // Do not use version-CAS so a user's concurrent edit of body etc. is not lost-updated (same idea as D-B1).
210
+ if (task.ticket_id !== null) {
211
+ db.prepare(`UPDATE tickets SET status = 'in_progress', updated_at = ?
212
+ WHERE id = ? AND status = 'open'`).run(now, task.ticket_id);
213
+ }
214
+ db.exec('COMMIT');
215
+ // The ticket transition's success/failure is not included in ClaimResult (backward compatible).
216
+ // The claim success is the primary contract, and the ticket transition is an idempotent side
217
+ // effect (no-op if already in_progress/closed), so the return contract is unchanged.
218
+ return { ok: true, task };
219
+ }
220
+ catch (e) {
221
+ // Always roll back on exception (do not exit while holding the lock).
222
+ try {
223
+ db.exec('ROLLBACK');
224
+ }
225
+ catch {
226
+ /* ignore if already closed */
227
+ }
228
+ throw e;
229
+ }
230
+ }
231
+ /**
232
+ * Explicit release. Only the claim holder can transition (the assignee match is a CAS condition).
233
+ * outcome='open' (default) = return to open (abandon so it can be re-claimed).
234
+ * outcome='abandoned' = make it abandoned (no longer worked on; not a re-claim target).
235
+ * The done transition is a separate path (the merge gate = task_done), so it is not handled here.
236
+ */
237
+ export function releaseTask(cdb, taskId, agentId, outcome = 'open') {
238
+ const now = unixNow();
239
+ const newStatus = outcome === 'abandoned' ? 'abandoned' : 'open';
240
+ const info = cdb.db
241
+ .prepare(`UPDATE tasks
242
+ SET assignee = NULL, status = ?, version = version + 1, heartbeat_at = NULL, updated_at = ?
243
+ WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
244
+ .run(newStatus, now, taskId, agentId);
245
+ // Issue #45 item 2: when a task is released (returned to open / abandoned), also release the active
246
+ // claims that have that task_id together. release is a "give up the work" operation, so leaving the
247
+ // tied claims would keep them appearing in overlaps as "finished work". Release for both open and
248
+ // abandoned. claim.ts cannot import files.ts (which exports sweepExpired) due to a cycle, so, like
249
+ // sweepExpired and heartbeat, write a raw UPDATE claims directly. Release by task_id match regardless of holder.
250
+ if (info.changes > 0) {
251
+ cdb.db
252
+ .prepare(`UPDATE claims SET released_at = ?
253
+ WHERE task_id = ? AND released_at IS NULL`)
254
+ .run(now, taskId);
255
+ // Issue #49 auto-resolution (main path, abandoned side): only when the task is dropped to
256
+ // abandoned (outcome='abandoned' and it actually transitioned, info.changes>0), auto-resolve the
257
+ // unresolved questions that have that task as ref_task_id. On a return to open (outcome='open'),
258
+ // the task is not finished yet, so do not resolve. resolvedBy is the releasing subject (agentId).
259
+ // Only unresolved ones are targeted, so it is idempotent (a double resolve is a no-op). board.ts
260
+ // imports claim.ts's sweepExpired, so importing claim.ts→board.ts would be a cycle. So instead of
261
+ // calling board.resolveQuestionsForTask, write the equivalent raw UPDATE here (the same style as
262
+ // releaseTask writing a raw UPDATE to claims).
263
+ if (outcome === 'abandoned') {
264
+ cdb.db
265
+ .prepare(`UPDATE board_messages
266
+ SET resolved_at = ?, resolved_by = ?
267
+ WHERE ref_task_id = ? AND kind = 'question' AND resolved_at IS NULL`)
268
+ .run(now, agentId, taskId);
269
+ }
270
+ }
271
+ return { ok: info.changes > 0 };
272
+ }
273
+ /**
274
+ * §4.x block_task — suspend to wait for a human decision (in_progress → blocked).
275
+ *
276
+ * Called before exiting when a headless worker cannot finish due to lacking permission / waiting for
277
+ * a design decision, etc. The decisive difference from release_task (return to open) / complete_task
278
+ * (done) is that it **keeps the assignee and heartbeat_at**: even suspended, it leaves "who got stuck
279
+ * in which worktree" in the state and preserves the worktree (assignment continuity = option (b)).
280
+ *
281
+ * blocked is excluded from the lease sweep (sweepExpired reclaims only status='in_progress'), so even
282
+ * if the worker process dies and heartbeat stops, it is **kept indefinitely** (the machine does not
283
+ * return it on its own). It is returned only by a human-decided resume_task (same-agent resume) /
284
+ * handoff (hand to another worker) / release_task (abandoned = cancel).
285
+ *
286
+ * The stuck reason is externalized to the board (post_board kind='question', ref_task_id) rather than
287
+ * adding a column to the task itself. Worker convention: leave the question first, then call
288
+ * block_task (since heartbeat stops at blocked, if it dies before writing the reason the way it got stuck is unknown).
289
+ *
290
+ * CAS: conditioned on "assignee = agentId and status = 'in_progress'". If it does not match, ok:false.
291
+ *
292
+ * Not taking expected_version is asymmetric with claimTask but intentional: claim needs version to
293
+ * detect the conflict where "multiple unassigned agents read the same open task and someone snatched
294
+ * it before claim". block/resume/handoff are transitions on a task whose assignee is already fixed to
295
+ * a single subject, and only that assignee is eligible. The only real conflict leaving blocked
296
+ * (concurrent resume ⇄ handoff) is serialized by the status condition + handoff's BEGIN IMMEDIATE,
297
+ * functioning as "status itself is the CAS" (leaving blocked happens only once). resume has no
298
+ * "freshness at read time" dependency like claim, so ABA is not harmful either.
299
+ */
300
+ export function blockTask(cdb, taskId, agentId) {
301
+ const now = unixNow();
302
+ const info = cdb.db
303
+ .prepare(`UPDATE tasks
304
+ SET status = 'blocked', version = version + 1, updated_at = ?
305
+ WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
306
+ .run(now, taskId, agentId);
307
+ // assignee / heartbeat_at are intentionally not updated (kept).
308
+ return { ok: info.changes > 0 };
309
+ }
310
+ /**
311
+ * §4.x resume_task — same-agent resume when a human says "continue" (blocked → in_progress).
312
+ *
313
+ * Since blockTask keeps the claim, the same logical agent can return it with an assignee match. The
314
+ * worktree stays as-is. heartbeat_at is updated to now to revive the lease (afterward it returns to
315
+ * being under sweep supervision as a normal in_progress). Handing to another worker is done not by
316
+ * resume but by handoff (only when a human says so explicitly).
317
+ *
318
+ * CAS: conditioned on "assignee = agentId and status = 'blocked'". If it does not match, ok:false
319
+ * (held by another agent / already in_progress/open, etc.).
320
+ *
321
+ * Like blockTask, it does not take expected_version (intentional; see blockTask's doc for the
322
+ * reason). Even if resume ⇄ handoff run concurrently, the status='blocked' in both WHERE clauses lets
323
+ * only one win first and the other gets changes==0, so it is serialized without needing version.
324
+ */
325
+ export function resumeTask(cdb, taskId, agentId) {
326
+ const now = unixNow();
327
+ const info = cdb.db
328
+ .prepare(`UPDATE tasks
329
+ SET status = 'in_progress', heartbeat_at = ?, version = version + 1, updated_at = ?
330
+ WHERE id = ? AND assignee = ? AND status = 'blocked'`)
331
+ .run(now, now, taskId, agentId);
332
+ return { ok: info.changes > 0 };
333
+ }
334
+ /**
335
+ * §4.7 handoff — **move a blocked task to another worker per worktree, all at once** (briefing D-3 decided).
336
+ *
337
+ * Adopted operation: resuming from suspension is, in principle, resume_task (same agent). Handing to
338
+ * another worker is **only when a human says so explicitly**, and this handoff is the sole path. So
339
+ * when this function is called, a human decision has already been made (the gate is outside the MCP
340
+ * tool = on the orchestrator/human side).
341
+ *
342
+ * "Per-worktree bulk move" means reassigning the following that the stuck agent A held to agent B, all
343
+ * in the **same CAS transaction**:
344
+ * 1. the task itself : blocked(assignee=A) → in_progress(assignee=B), heartbeat revived
345
+ * 2. worktree_assignments : this task's active worktree from agent A → B (branch/path unchanged = physical side as-is)
346
+ * 3. claims (glob lease) : active claims with task_id=this task and holder=A to holder B
347
+ * 4. dirty_sets : the (agent_id=A) rows of the moved worktree to agent_id=B
348
+ * This way B inherits "the worktree A physically created, the claims it held, and the published dirty
349
+ * set" wholesale and can continue from where the predecessor's work surface left off (the process
350
+ * cannot be switched over, but the work can).
351
+ *
352
+ * Preventing double handoff: BEGIN IMMEDIATE + a CAS of "assignee=fromAgent and status='blocked'".
353
+ * If it was already moved to another agent / resumed / is held by someone other than A, changes==0 →
354
+ * conflict and nothing moves. No new exclusion mechanism is added (follows the existing CAS style).
355
+ *
356
+ * worktree_path/branch are not changed: the physical worktree stays as-is on the FS, and only the
357
+ * owner (agent_id) is reassigned. B cd's into this worktree path and continues. As a measure for
358
+ * dirty_sets' UNIQUE(agent_id, worktree_path), in case B previously left a dirty row for the same
359
+ * worktree, DELETE B's leftover for the same worktree in the same transaction before the UPDATE
360
+ * (since a history of A↔B going back and forth over the same deterministic worktree via §4.5
361
+ * reconnection could make (B, path) duplicate).
362
+ *
363
+ * Limiting the entry condition to blocked is intentional: being able to snatch-move an in_progress
364
+ * task would be a "steal work from a live worker" accident. A move always follows the order "suspend
365
+ * (blocked), then a human names another worker".
366
+ */
367
+ export function handoffTask(cdb, args) {
368
+ const db = cdb.db;
369
+ const now = unixNow();
370
+ const { taskId, fromAgent, toAgent } = args;
371
+ db.exec('BEGIN IMMEDIATE');
372
+ try {
373
+ // 1) Move the task itself A→B by CAS (only blocked and assignee=A). This is the gate against double handoff.
374
+ const taskInfo = db
375
+ .prepare(`UPDATE tasks
376
+ SET assignee = ?, status = 'in_progress', heartbeat_at = ?,
377
+ version = version + 1, updated_at = ?
378
+ WHERE id = ? AND assignee = ? AND status = 'blocked'`)
379
+ .run(toAgent, now, now, taskId, fromAgent);
380
+ if (taskInfo.changes === 0) {
381
+ const current = db
382
+ .prepare('SELECT assignee, status, version FROM tasks WHERE id = ?')
383
+ .get(taskId);
384
+ db.exec('ROLLBACK');
385
+ if (!current)
386
+ return { ok: false, reason: 'not_found' };
387
+ return { ok: false, reason: 'conflict', current };
388
+ }
389
+ // 2) Move this task's active worktree A→B. Pick up the paths first, then reassign dirty_sets.
390
+ const wtRows = db
391
+ .prepare(`SELECT worktree_path FROM worktree_assignments
392
+ WHERE agent_id = ? AND task_id = ? AND status = 'active'`)
393
+ .all(fromAgent, taskId);
394
+ const worktrees = wtRows.map((r) => r.worktree_path);
395
+ const wtInfo = db
396
+ .prepare(`UPDATE worktree_assignments
397
+ SET agent_id = ?
398
+ WHERE agent_id = ? AND task_id = ? AND status = 'active'`)
399
+ .run(toAgent, fromAgent, taskId);
400
+ // 3) Move active claims tied to this task (holder=A) to B. Narrow by task_id match
401
+ // (do not sweep in claims A holds for another task = faithful to a per-worktree move).
402
+ const claimInfo = db
403
+ .prepare(`UPDATE claims
404
+ SET holder = ?
405
+ WHERE holder = ? AND task_id = ? AND released_at IS NULL`)
406
+ .run(toAgent, fromAgent, taskId);
407
+ // 4) Move the moved worktree's dirty_sets A→B. Per-worktree, so only (agent_id=A and
408
+ // worktree_path IN moved-worktrees).
409
+ // UNIQUE(agent_id, worktree_path) measure: if B previously left a dirty_set for the same
410
+ // worktree_path (a history of A↔B going back and forth over the same deterministic worktree via
411
+ // §4.5 reconnection), the moment agent_id is rewritten A→B, (B, path) would duplicate, SQLite
412
+ // raises an exception → full rollback. So before the reassignment, clean up B's leftover for
413
+ // the same worktree in the same transaction, then UPDATE. The cleanup target is limited to "the
414
+ // worktree currently being moved from A to B" (B's unrelated dirty_set is preserved).
415
+ let dirtyMoved = 0;
416
+ if (worktrees.length > 0) {
417
+ const placeholders = worktrees.map(() => '?').join(',');
418
+ db.prepare(`DELETE FROM dirty_sets
419
+ WHERE agent_id = ? AND worktree_path IN (${placeholders})`).run(toAgent, ...worktrees);
420
+ const dirtyInfo = db
421
+ .prepare(`UPDATE dirty_sets
422
+ SET agent_id = ?
423
+ WHERE agent_id = ? AND worktree_path IN (${placeholders})`)
424
+ .run(toAgent, fromAgent, ...worktrees);
425
+ dirtyMoved = Number(dirtyInfo.changes);
426
+ }
427
+ db.exec('COMMIT');
428
+ return {
429
+ ok: true,
430
+ moved: {
431
+ worktrees: Number(wtInfo.changes),
432
+ claims: Number(claimInfo.changes),
433
+ dirty_sets: dirtyMoved,
434
+ },
435
+ worktrees,
436
+ };
437
+ }
438
+ catch (e) {
439
+ try {
440
+ db.exec('ROLLBACK');
441
+ }
442
+ catch {
443
+ /* already closed */
444
+ }
445
+ throw e;
446
+ }
447
+ }
448
+ /**
449
+ * Set a task to done (called from the merge gate on a successful merge).
450
+ * The same CAS style as claimTask: conditioned on "assignee = agentId and status = 'in_progress'",
451
+ * update status='done', version+1, and updated_at.
452
+ *
453
+ * The only legitimate basis for the done transition is the fact of "merged into the main line", so
454
+ * this function is called only at merge.ts's merge-success point (follows the role separation from
455
+ * release_task = return-to-open only). If it does not match, ok:false (held by another agent /
456
+ * already done/open, etc.).
457
+ */
458
+ export function completeTask(cdb, taskId, agentId) {
459
+ const now = unixNow();
460
+ const info = cdb.db
461
+ .prepare(`UPDATE tasks
462
+ SET status = 'done', assignee = NULL, version = version + 1,
463
+ heartbeat_at = NULL, updated_at = ?
464
+ WHERE id = ? AND assignee = ? AND status = 'in_progress'`)
465
+ .run(now, taskId, agentId);
466
+ return { ok: info.changes > 0 };
467
+ }
468
+ export function listBlockedTasks(cdb, args = {}) {
469
+ const db = cdb.db;
470
+ const now = args.now ?? unixNow();
471
+ const clauses = [`t.status = 'blocked'`];
472
+ const params = [];
473
+ if (args.repo) {
474
+ clauses.push('t.repo = ?');
475
+ params.push(args.repo);
476
+ }
477
+ if (args.assignee) {
478
+ clauses.push('t.assignee = ?');
479
+ params.push(args.assignee);
480
+ }
481
+ if (args.ticketId) {
482
+ clauses.push('t.ticket_id = ?');
483
+ params.push(args.ticketId);
484
+ }
485
+ const orderBy = args.order === 'priority' ? 't.priority DESC, t.created_at ASC' : 't.updated_at ASC';
486
+ const rows = db
487
+ .prepare(`SELECT t.*,
488
+ a.last_seen_at AS _agent_last_seen,
489
+ -- Issue #49 design decision: do not keep emitting a resolved question as last_question
490
+ -- (resolved_at IS NULL = only unresolved). last_question's meaning is unchanged = the
491
+ -- latest unresolved question body (null if none). A resolved question is no longer the
492
+ -- "stuck reason" in the orphan-visualization context.
493
+ (SELECT b.body FROM board_messages b
494
+ WHERE b.ref_task_id = t.id AND b.kind = 'question' AND b.resolved_at IS NULL
495
+ ORDER BY b.seq DESC LIMIT 1) AS _last_question
496
+ FROM tasks t
497
+ LEFT JOIN agents a ON a.agent_id = t.assignee
498
+ WHERE ${clauses.join(' AND ')}
499
+ ORDER BY ${orderBy}`)
500
+ .all(...params);
501
+ return rows.map((r) => {
502
+ const { _agent_last_seen, _last_question, ...task } = r;
503
+ return {
504
+ ...task,
505
+ blocked_age_sec: now - task.updated_at,
506
+ agent_silent_sec: _agent_last_seen == null ? null : now - _agent_last_seen,
507
+ last_question: _last_question ?? null,
508
+ };
509
+ });
510
+ }
511
+ /**
512
+ * §4.2 lease sweep — reclaim expired claims + return heartbeat-lapsed tasks to open.
513
+ * Assumed to run piggybacked at the head of each tool call rather than a resident cron.
514
+ */
515
+ export function sweepExpired(cdb, now = unixNow()) {
516
+ const db = cdb.db;
517
+ // 1) Logically reclaim expired claims (no physical DELETE = for provenance tracking)
518
+ db.prepare(`UPDATE claims SET released_at = ?
519
+ WHERE released_at IS NULL AND expires_at < ?`).run(now, now);
520
+ // 2) Return heartbeat-lapsed in_progress tasks to open
521
+ db.prepare(`UPDATE tasks
522
+ SET status = 'open', assignee = NULL, version = version + 1, updated_at = ?
523
+ WHERE status = 'in_progress' AND heartbeat_at < ?`).run(now, now - TASK_LEASE_SEC);
524
+ // 3) agents' liveness is judged by last_seen (for worktree orphan detection). No auto release.
525
+ }
526
+ /**
527
+ * The heartbeat piggybacked onto every tool call. Updates the worker's own liveness time
528
+ * (agents.last_seen) and the lease of held tasks/claims.
529
+ *
530
+ * The 3rd argument also accepts a taskId string for backward compatibility (= same as { taskId }).
531
+ * Passing touchAgent=false suppresses the extension of agents.last_seen and the claims lease
532
+ * (for operations invoked by non-owners = the orchestrator/human; prevents polluting orphan detection).
533
+ *
534
+ * Issue #51 handling: even on a piggyback call that passes no taskId (heartbeat(cdb, agentId)), if
535
+ * touchOwnedTasks (default = touchAgent) is true, extend the heartbeat_at of all in_progress tasks
536
+ * assigned to that agent to now. This way the task lease is maintained by the piggyback of each tool
537
+ * call alone, even without calling the heartbeat tool (with taskId), and prevents wrongful
538
+ * reclamation by sweepExpired.
539
+ */
540
+ export function heartbeat(cdb, agentId, opts) {
541
+ const db = cdb.db;
542
+ const now = unixNow();
543
+ const o = typeof opts === 'string' ? { taskId: opts } : (opts ?? {});
544
+ const touchAgent = o.touchAgent ?? true;
545
+ // Owned tasks' lease extension only on the owner's liveness signal (do not freshen for non-owner =
546
+ // touchAgent:false operations). So align the default to touchAgent. An explicit value takes precedence.
547
+ const touchOwnedTasks = o.touchOwnedTasks ?? touchAgent;
548
+ if (touchAgent) {
549
+ // Update agents.last_seen (upsert if absent). Only on the worker's own liveness signal.
550
+ db.prepare(`INSERT INTO agents (agent_id, label, last_seen_at, created_at, host_id, origin)
551
+ VALUES (?, NULL, ?, ?, ?, 'local')
552
+ ON CONFLICT(agent_id) DO UPDATE SET last_seen_at = excluded.last_seen_at`).run(agentId, now, now, cdb.hostId);
553
+ }
554
+ if (o.taskId) {
555
+ // When taskId is explicit, update only "that one (assignee match)" as before.
556
+ db.prepare(`UPDATE tasks SET heartbeat_at = ? WHERE id = ? AND assignee = ?`).run(now, o.taskId, agentId);
557
+ }
558
+ else if (touchOwnedTasks) {
559
+ // Issue #51: even without taskId (= the piggyback of each tool call), extend the lease of all
560
+ // in_progress tasks held by this agent. blocked is kept indefinitely and excluded from sweep, so
561
+ // do not touch it. claim.ts does not import board/files (to avoid a cycle), so write the equivalent raw UPDATE directly.
562
+ db.prepare(`UPDATE tasks SET heartbeat_at = ? WHERE assignee = ? AND status = 'in_progress'`).run(now, agentId);
563
+ }
564
+ if (touchAgent) {
565
+ // Extend the lease of held claims (tied to the owner's liveness, so linked to touchAgent).
566
+ //
567
+ // Issue #45 item 4: narrow the extension target to "claims not tied to a task (task_id IS NULL)"
568
+ // or "claims of still-live tasks (in_progress / blocked)". Previously all active claims the holder
569
+ // had were extended without narrowing by task_id, so as long as the agent continued another task,
570
+ // expires_at kept extending even for claims of tasks already dropped to done / abandoned / open
571
+ // and they were not reclaimed even by TTL (the cause of finished work's claims lingering in
572
+ // overlaps). Exclude claims tied to done / abandoned / open tasks from the extension target and
573
+ // leave them to the item 1-3 release or TTL expiry.
574
+ db.prepare(`UPDATE claims SET heartbeat_at = ?, expires_at = ? + lease_ttl_sec
575
+ WHERE holder = ? AND released_at IS NULL
576
+ AND (task_id IS NULL
577
+ OR task_id IN (SELECT id FROM tasks WHERE status IN ('in_progress', 'blocked')))`).run(now, now, agentId);
578
+ }
579
+ }
580
+ //# sourceMappingURL=claim.js.map