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,831 @@
1
+ import { join } from 'node:path';
2
+ import { tmpdir } from 'node:os';
3
+ import { ulid } from 'ulid';
4
+ import { unixNow } from '../db/index.js';
5
+ import { fileIntersect } from './globs.js';
6
+ import { git, resolveBaseBranch, short } from './git.js';
7
+ import { releaseWorktree } from './worktree.js';
8
+ import { postBoard, wake, isReady, resolveQuestionsForTask } from './board.js';
9
+ import { completeTask, claimTask } from './claim.js';
10
+ import { releaseClaimsForTask } from './files.js';
11
+ /** Read the dirty set (file array) of the given worktree. Empty array if none. */
12
+ function dirtyFiles(cdb, agentId, worktreePath) {
13
+ const row = cdb.db
14
+ .prepare(`SELECT files FROM dirty_sets WHERE agent_id = ? AND worktree_path = ?`)
15
+ .get(agentId, worktreePath);
16
+ if (!row)
17
+ return [];
18
+ try {
19
+ const v = JSON.parse(row.files);
20
+ return Array.isArray(v) ? v : [];
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ /** Clear the dirty-set row of the given worktree (§4.5: cleared on mainline reflection or discard). */
27
+ function clearDirty(cdb, agentId, worktreePath) {
28
+ cdb.db
29
+ .prepare(`DELETE FROM dirty_sets WHERE agent_id = ? AND worktree_path = ?`)
30
+ .run(agentId, worktreePath);
31
+ }
32
+ /**
33
+ * Real git conflict detection (mitigation for issue "high-2"). Instead of relying on the advisory
34
+ * dirty set, it uses `git merge-tree` to actually run the 3-way comparison of base/mainline/branch
35
+ * and lets git decide whether they conflict. No false negatives even when the dirty set was never published.
36
+ * @returns the list of conflicting files (empty = can merge cleanly). null when git fails (cannot decide).
37
+ */
38
+ function gitConflictFiles(repo, baseBranch, branch) {
39
+ try {
40
+ const base = git(repo, ['merge-base', baseBranch, branch]);
41
+ // `git merge-tree --write-tree` (git 2.38+) emits conflicts in a machine-readable form.
42
+ // Even on a non-zero exit the conflict info is written to stdout, so catch the exception and parse it.
43
+ let out;
44
+ try {
45
+ out = git(repo, ['merge-tree', '--write-tree', '--name-only', baseBranch, branch]);
46
+ // Not non-zero = no conflict.
47
+ void base;
48
+ return [];
49
+ }
50
+ catch (e) {
51
+ // merge-tree exits non-zero on conflict. stdout format (--write-tree --name-only):
52
+ // <tree-oid>\n <conflicting file name>...\n <blank line>\n <"Auto-merging"/"CONFLICT" messages>...
53
+ // The conflicting file names run from the line after line 1 (the tree oid) up to the first blank
54
+ // line. Everything after the blank line is messages, so discard it.
55
+ const stdout = e.stdout;
56
+ if (stdout == null)
57
+ return null; // cannot parse (old git, etc.) → cannot decide
58
+ const text = typeof stdout === 'string' ? stdout : stdout.toString('utf8');
59
+ const lines = text.split('\n');
60
+ const files = [];
61
+ for (let i = 1; i < lines.length; i++) {
62
+ if (lines[i] === '')
63
+ break; // blank line = end of the info block
64
+ files.push(lines[i]);
65
+ }
66
+ return files;
67
+ }
68
+ }
69
+ catch {
70
+ return null; // merge-base etc. unavailable → cannot decide
71
+ }
72
+ }
73
+ /**
74
+ * Safe merge (mitigation for issues "high-1"/"high-4", Issue #25 option 1).
75
+ *
76
+ * To never touch the main repository's working tree, checkout state, or uncommitted changes,
77
+ * the merge is performed on a **temporary worktree** (Issue #25).
78
+ * (1) `git(repo, ['worktree', 'add', <tmp>, baseBranch])` checks baseBranch out to a temporary worktree.
79
+ * (2) `git(<tmp>, ['merge', '--no-ff', ...])` runs the merge on that temporary worktree. Because baseBranch
80
+ * is a shared ref, on success baseBranch advances to the merge commit as seen from the main repo too.
81
+ * (3) The temporary worktree is cleaned up with remove/prune regardless of success. On failure, discarding
82
+ * the whole temporary worktree restores the original state, so `git merge --abort` is unnecessary.
83
+ *
84
+ * 1-C edge case: if baseBranch is currently checked out in the main repo, git refuses the double checkout
85
+ * of the same branch and `worktree add` fails. Only in that case do we merge directly on the main repo
86
+ * without a temporary worktree (the main repo is already on baseBranch, so no checkout and no branch
87
+ * switch happen). Only on failure of the main-repo merge path do we restore with `git merge --abort`.
88
+ *
89
+ * Throws nothing; returns the result `{ ok: true } | { ok: false; error: string }`. Signature unchanged.
90
+ */
91
+ function safeMerge(repo, baseBranch, branch, pathTag) {
92
+ // Check the main repo's current branch. If it equals baseBranch, worktree add would fail with a
93
+ // double checkout, so merge directly on the main repo (the 1-C edge case). If rev-parse throws
94
+ // (detached HEAD, etc.), conservatively fall back to the temporary-worktree approach.
95
+ let headBranch = null;
96
+ try {
97
+ headBranch = git(repo, ['rev-parse', '--abbrev-ref', 'HEAD']);
98
+ }
99
+ catch {
100
+ headBranch = null;
101
+ }
102
+ // Invariant (review Issue #3): this headBranch check only looks at the main repo's HEAD.
103
+ // If another worktree holds baseBranch, worktree add fails with a double checkout; it does not
104
+ // enter the edge-case branch and instead returns the `{ ok: false, error: 'worktree add ... failed' }`
105
+ // below. In the current design, assigned-worktree branches use worktree.ts's deterministic naming
106
+ // `lockage/<agent>/<slug>`, which never collides with baseBranch (dev/stg/main/master), so this
107
+ // situation is unreachable. If that naming convention changes in the future, note that the premise
108
+ // here (deciding double-checkout eligibility from the main HEAD alone) breaks.
109
+ if (headBranch === baseBranch) {
110
+ // The main repo is already on baseBranch → merge on the main repo as-is without checkout (no branch switch).
111
+ try {
112
+ git(repo, ['merge', '--no-ff', '-m', `merge ${branch} (${pathTag})`, branch]);
113
+ return { ok: true };
114
+ }
115
+ catch (e) {
116
+ // Merge failed → abort and restore the main working tree to its original state (high-1/high-4).
117
+ // Regarding review Issue #2 (main repo dirty + edge case + merge failure): with git's real
118
+ // behavior this merge --abort will not drag in and discard uncommitted changes in the main repo.
119
+ // (a) If uncommitted changes touch a file in the merge, git aborts before the merge starts with
120
+ // "local changes would be overwritten" (the working tree is not changed at all).
121
+ // (b) If uncommitted changes are in unrelated files, merge --abort only reverts what the merge
122
+ // touched back to its pre-merge state, so unrelated uncommitted changes are preserved as-is.
123
+ // In both cases uncommitted changes are not lost. When merge --abort is a no-op (no merge to abort)
124
+ // it exits non-zero, but we swallow that best-effort.
125
+ try {
126
+ git(repo, ['merge', '--abort']);
127
+ }
128
+ catch {
129
+ /* best effort even if the abort itself fails */
130
+ }
131
+ return { ok: false, error: `merge ${branch} failed: ${errMsg(e)}` };
132
+ }
133
+ }
134
+ // Temporary-worktree approach: merge without touching the main repo at all. The path lives outside
135
+ // the repo (under tmpdir).
136
+ const tmp = join(tmpdir(), `lockage-merge-${ulid()}`);
137
+ try {
138
+ git(repo, ['worktree', 'add', tmp, baseBranch]);
139
+ }
140
+ catch (e) {
141
+ // worktree add itself failed → nothing was created, so just prune best-effort.
142
+ try {
143
+ git(repo, ['worktree', 'prune']);
144
+ }
145
+ catch {
146
+ /* best effort */
147
+ }
148
+ return { ok: false, error: `worktree add for ${baseBranch} failed: ${errMsg(e)}` };
149
+ }
150
+ try {
151
+ // Merge on the temporary worktree (git -C <tmp>). On success the shared ref baseBranch advances.
152
+ git(tmp, ['merge', '--no-ff', '-m', `merge ${branch} (${pathTag})`, branch]);
153
+ return { ok: true };
154
+ }
155
+ catch (e) {
156
+ // Merge failed → discarding the whole temporary worktree restores the original state (merge --abort unneeded).
157
+ return { ok: false, error: `merge ${branch} failed: ${errMsg(e)}` };
158
+ }
159
+ finally {
160
+ // Clean up the temporary worktree regardless of success (leave no debris). Swallow failures best-effort.
161
+ try {
162
+ git(repo, ['worktree', 'remove', '--force', tmp]);
163
+ }
164
+ catch {
165
+ /* best effort */
166
+ }
167
+ try {
168
+ git(repo, ['worktree', 'prune']);
169
+ }
170
+ catch {
171
+ /* best effort */
172
+ }
173
+ }
174
+ }
175
+ /** Extract a short message from an exception (includes git stderr). */
176
+ function errMsg(e) {
177
+ const err = e;
178
+ if (err?.stderr != null) {
179
+ return (typeof err.stderr === 'string' ? err.stderr : err.stderr.toString('utf8')).trim().slice(0, 500);
180
+ }
181
+ return err?.message ? err.message.slice(0, 500) : String(e).slice(0, 500);
182
+ }
183
+ /**
184
+ * Mark a successfully merged task done, record the completion event, and wake dependent agents.
185
+ * - If taskId is null (a merge not tied to a task), the status is left untouched.
186
+ * - completeTask is a CAS (assignee=by and in_progress). If it does not match, the task is not set to
187
+ * done, but the merge itself succeeded so processing continues (only the status update is skipped).
188
+ * - Once done, send a task_ready wake to the assignees of incomplete tasks this task blocks
189
+ * (they resume via the task_ready path of wait_for_unblock).
190
+ * @returns completed = whether the task could be set to done (taskId=null has nothing to complete, so true).
191
+ * woke = array of agent_ids woken by the unblock (reflected into wake_targets).
192
+ */
193
+ function finalizeMergedTask(cdb, by, taskId) {
194
+ if (!taskId)
195
+ return { completed: true, woke: [] };
196
+ const r = completeTask(cdb, taskId, by);
197
+ if (!r.ok) {
198
+ // CAS mismatch (lease sweep reopened it, reassigned, etc.). It is merged into the mainline but
199
+ // cannot be set to done = "merged but not done". We do not set done, but the merge succeeded so
200
+ // processing continues.
201
+ return { completed: false, woke: [] };
202
+ }
203
+ // Issue #45 item 1: once the task is done, release the active claims that hold this task_id.
204
+ // We release here because finalizeMergedTask is the only done-confirmation point that both the
205
+ // 1-B (via integration branch) and 1-C (direct to base) paths pass through, and it runs after
206
+ // completeTask succeeds (= merged into the mainline). Release by task_id regardless of holder, so
207
+ // claims made after a handoff are not left behind.
208
+ releaseClaimsForTask(cdb, taskId);
209
+ // Issue #49 auto-resolution (main path, done side): once the task is done, auto-resolve unresolved
210
+ // questions that hold this task as ref_task_id. This is the only done-confirmation point both the
211
+ // 1-B/1-C paths pass through (completion after a handoff also passes here), so no done path's
212
+ // unresolved question is missed. Called after completeTask succeeds (r.ok), so only questions whose
213
+ // done is confirmed by a mainline merge are closed. resolvedBy is the assignee who confirmed done
214
+ // (= by, the one who completed this task). Idempotent (only unresolved ones are targeted).
215
+ resolveQuestionsForTask(cdb, { taskId, resolvedBy: by });
216
+ // Wake the assignees of downstream tasks this task blocks (early unblock notification).
217
+ const downstream = cdb.db
218
+ .prepare(`SELECT t.id AS id, t.assignee AS assignee
219
+ FROM task_edges e JOIN tasks t ON t.id = e.to_task
220
+ WHERE e.from_task = ? AND e.kind = 'blocks'
221
+ AND t.assignee IS NOT NULL AND t.status != 'done'`)
222
+ .all(taskId);
223
+ const woke = new Set();
224
+ for (const d of downstream) {
225
+ if (!d.assignee || !isReady(cdb, d.id))
226
+ continue; // do not wake if other blockers remain
227
+ wake(cdb, d.assignee, 'task_ready', { reason: 'dependency_done', task: taskId, ready_task: d.id });
228
+ woke.add(d.assignee);
229
+ }
230
+ return { completed: true, woke: [...woke] };
231
+ }
232
+ /**
233
+ * Compute "files touched by mainline ∩ my dirty set" via a 3-way precheck (§4.6).
234
+ * All repo-scoped (`git -C <repo>`). The mainline branch is resolved via resolveBaseBranch.
235
+ *
236
+ * Mitigation for issue "high-2": a decision that depends on the advisory dirty set produces a false
237
+ * negative when publish_dirty was never called. So also use real git conflict detection
238
+ * (`git merge-tree`), and treat a file as contested if either side flags it (fail-safe). If git cannot
239
+ * decide (old git, etc.), fall back to advisory only.
240
+ * @returns contested = conflict-candidate files (empty = can merge safely).
241
+ */
242
+ function threeWayPrecheck(cdb, item) {
243
+ // A-1: the aggregation target prefers item.targetBranch (integration branch), else base. From here on
244
+ // base/mainFiles/merge-tree are all relative to this target (when aggregating, "files touched by the
245
+ // integration branch ∩ my dirty set").
246
+ const baseBranch = item.targetBranch ?? resolveBaseBranch(item.repo);
247
+ const base = git(item.repo, ['merge-base', baseBranch, item.branch]);
248
+ // Files changed base→mainline (touched by the latest mainline)
249
+ const mainFilesRaw = git(item.repo, ['diff', '--name-only', base, baseBranch]);
250
+ const mainFiles = mainFilesRaw ? mainFilesRaw.split('\n').filter(Boolean) : [];
251
+ const myFiles = dirtyFiles(cdb, item.agentId, item.worktreePath);
252
+ // advisory side: touched by mainline ∩ my dirty set (the strict decision of §4.0)
253
+ const advisory = fileIntersect(mainFiles, myFiles);
254
+ // git-real side: detect real conflicts with merge-tree (no false negative even if dirty was unpublished)
255
+ const gitConflicts = gitConflictFiles(item.repo, baseBranch, item.branch);
256
+ if (gitConflicts === null)
257
+ return advisory; // git cannot decide → advisory only
258
+ // union of what either side flagged (fail-safe)
259
+ return [...new Set([...advisory, ...gitConflicts])];
260
+ }
261
+ /**
262
+ * §4.6 enqueue_merge — push a completed worktree onto the serial merge gate (1-B).
263
+ * seq follows the same numbering discipline as board (MAX+1 inside BEGIN IMMEDIATE): monotonically
264
+ * increasing and unique.
265
+ */
266
+ export function enqueueMerge(cdb, item) {
267
+ const db = cdb.db;
268
+ const now = unixNow();
269
+ const id = ulid();
270
+ db.exec('BEGIN IMMEDIATE');
271
+ try {
272
+ const row = db
273
+ .prepare(`SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM merge_queue`)
274
+ .get();
275
+ const seq = row.next;
276
+ db.prepare(`INSERT INTO merge_queue
277
+ (id, seq, agent_id, worktree_path, branch, task_id, repo, state, precheck,
278
+ enqueued_at, processed_at, host_id, origin, target_branch)
279
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, ?, NULL, ?, 'local', ?)`).run(id, seq, item.agentId, item.worktreePath, item.branch, item.taskId ?? null, item.repo, now, cdb.hostId, item.targetBranch ?? null);
280
+ db.exec('COMMIT');
281
+ return { merge_id: id, position: seq };
282
+ }
283
+ catch (e) {
284
+ try {
285
+ db.exec('ROLLBACK');
286
+ }
287
+ catch {
288
+ /* already closed */
289
+ }
290
+ throw e;
291
+ }
292
+ }
293
+ /**
294
+ * §4.6 process_merge_queue — the serial merge gate (1-B).
295
+ * Processes one row at a time in ascending seq. If a row is already merging, does not process concurrently.
296
+ * If the 3-way precheck finds zero conflict candidates, auto-merge; if there are candidates, send it back
297
+ * as needs_review (no hard block — a human/the assignee resolves it in the worktree).
298
+ * Uses a while loop rather than tail recursion for the next item (avoids stack overflow on long queues).
299
+ */
300
+ export function processMergeQueue(cdb) {
301
+ const db = cdb.db;
302
+ const merged = [];
303
+ const needsReview = [];
304
+ const wakeTargets = new Set();
305
+ while (true) {
306
+ // high-3: atomize "confirm nothing is inflight" and "secure queued→merging" in a single
307
+ // BEGIN IMMEDIATE, turning it into a CAS with a conditional UPDATE + changes check. This way even if
308
+ // multiple processes share the same DB, the same row cannot be set to merging twice (apply the same
309
+ // serialization as the seq numbering to the driving side too).
310
+ let item;
311
+ db.exec('BEGIN IMMEDIATE');
312
+ try {
313
+ const inflight = db
314
+ .prepare(`SELECT 1 FROM merge_queue WHERE state = 'merging' LIMIT 1`)
315
+ .get();
316
+ if (inflight) {
317
+ db.exec('COMMIT');
318
+ break; // another path is processing
319
+ }
320
+ const candidate = db
321
+ .prepare(`SELECT * FROM merge_queue WHERE state = 'queued' ORDER BY seq ASC LIMIT 1`)
322
+ .get();
323
+ if (!candidate) {
324
+ db.exec('COMMIT');
325
+ break; // queue empty
326
+ }
327
+ const info = db
328
+ .prepare(`UPDATE merge_queue SET state = 'merging' WHERE id = ? AND state = 'queued'`)
329
+ .run(candidate.id);
330
+ if (info.changes === 0) {
331
+ // Another process secured it first. Roll back and re-fetch on the next loop.
332
+ db.exec('COMMIT');
333
+ continue;
334
+ }
335
+ item = candidate;
336
+ db.exec('COMMIT');
337
+ }
338
+ catch (e) {
339
+ try {
340
+ db.exec('ROLLBACK');
341
+ }
342
+ catch {
343
+ /* already closed */
344
+ }
345
+ throw e;
346
+ }
347
+ if (!item)
348
+ break;
349
+ const now = unixNow();
350
+ // high-1 (the key one): wrap the entire processing block "after" merging is secured in a try.
351
+ // If `git merge-base`/`git diff` inside threeWayPrecheck throws (nonexistent branch, ENOBUFS,
352
+ // corrupt repo, etc.), without this guard the row stays merging and the inflight check at the top
353
+ // permanently halts the queue thereafter. In catch, terminalize the row as needs_review and notify,
354
+ // then continue the loop (never let the throw escape).
355
+ try {
356
+ const contested = threeWayPrecheck(cdb, {
357
+ agentId: item.agent_id,
358
+ worktreePath: item.worktree_path,
359
+ branch: item.branch,
360
+ repo: item.repo,
361
+ taskId: item.task_id,
362
+ targetBranch: item.target_branch, // A-1: deliver the aggregation target (integration branch) to the precheck (d)
363
+ });
364
+ if (contested.length === 0) {
365
+ // No conflict candidates → real merge. On git failure safeMerge discards the temporary worktree,
366
+ // restores the original state, and returns the result (high-1). The main working tree / checkout
367
+ // state are untouched (Issue #25). A-1(e): the target prefers target_branch (integration branch),
368
+ // else base. safeMerge takes baseBranch as an argument, so the signature is unchanged.
369
+ const baseBranch = item.target_branch ?? resolveBaseBranch(item.repo);
370
+ const res = safeMerge(item.repo, baseBranch, item.branch, '1-B');
371
+ if (res.ok) {
372
+ db.prepare(`UPDATE merge_queue SET state = 'merged', processed_at = ? WHERE id = ?`).run(now, item.id);
373
+ clearDirty(cdb, item.agent_id, item.worktree_path);
374
+ const woke = finalizeMergedTask(cdb, item.agent_id, item.task_id);
375
+ for (const a of woke.woke)
376
+ wakeTargets.add(a);
377
+ merged.push({ task_id: item.task_id, branch: item.branch, path: '1-B' });
378
+ // A-3 path 1 (this process merged): when aggregating (target_branch present), if this done makes
379
+ // all tasks under the ticket done, emit ticket_ready (idempotent via ticket_ready_at CAS = no
380
+ // double emit with path 2). Only tasks with a ticket_id carry target_branch (decideTarget sets
381
+ // isIntegration=true only when ticket_id is present). Even a 1-task ticket carries target_branch,
382
+ // so it is emitted naturally.
383
+ if (item.target_branch && item.task_id) {
384
+ const ticketRow = db
385
+ .prepare(`SELECT ticket_id FROM tasks WHERE id = ?`)
386
+ .get(item.task_id);
387
+ tryEmitTicketReady(cdb, ticketRow?.ticket_id ?? null);
388
+ }
389
+ }
390
+ else {
391
+ // git merge failed → do not leave it merging; terminalize to needs_review (high-1: prevent queue halt).
392
+ db.prepare(`UPDATE merge_queue SET state = 'needs_review', precheck = json(?), processed_at = ? WHERE id = ?`).run(JSON.stringify({ error: res.error }), now, item.id);
393
+ needsReview.push({ task_id: item.task_id, branch: item.branch, contested: [], error: res.error });
394
+ notifyNeedsReview(cdb, item, [], res.error);
395
+ wakeTargets.add(item.agent_id);
396
+ }
397
+ }
398
+ else {
399
+ // Conflict candidates → send back as needs_review (a human/the assignee resolves it; no hard block).
400
+ db.prepare(`UPDATE merge_queue SET state = 'needs_review', precheck = json(?), processed_at = ? WHERE id = ?`).run(JSON.stringify({ contested }), now, item.id);
401
+ needsReview.push({ task_id: item.task_id, branch: item.branch, contested });
402
+ notifyNeedsReview(cdb, item, contested);
403
+ wakeTargets.add(item.agent_id);
404
+ }
405
+ }
406
+ catch (e) {
407
+ // high-1 key: swallow unexpected throws from threeWayPrecheck / finalizeMergedTask / anything else
408
+ // here. Do not leave the row merging; terminalize to needs_review and advance the queue.
409
+ const message = errMsg(e);
410
+ db.prepare(`UPDATE merge_queue SET state = 'needs_review', precheck = json(?), processed_at = ? WHERE id = ?`).run(JSON.stringify({ error: message }), unixNow(), item.id);
411
+ needsReview.push({ task_id: item.task_id, branch: item.branch, contested: [], error: message });
412
+ notifyNeedsReview(cdb, item, [], message);
413
+ wakeTargets.add(item.agent_id);
414
+ // continue the loop (no break/throw) — subsequent queued rows are processed normally.
415
+ }
416
+ }
417
+ return { merged, needs_review: needsReview, wake_targets: [...wakeTargets] };
418
+ }
419
+ /** Notify the assignee of needs_review via board handoff + A2A wake (§4.6). */
420
+ function notifyNeedsReview(cdb, item, contested, error) {
421
+ const body = error
422
+ ? `merge failed, needs review: ${error}`
423
+ : `merge needs review: ${contested.join(', ')}`;
424
+ postBoard(cdb, {
425
+ agentId: 'system',
426
+ kind: 'handoff',
427
+ toAgent: item.agent_id,
428
+ body,
429
+ refTaskId: item.task_id,
430
+ });
431
+ wake(cdb, item.agent_id, 'board', {
432
+ reason: error ? 'merge_failed' : 'merge_needs_review',
433
+ task: item.task_id,
434
+ contested,
435
+ error,
436
+ });
437
+ }
438
+ /**
439
+ * §4.1 decideTarget — decide the aggregation target of a task.
440
+ * - no task_id / no ticket_id (a minor task) → merge directly to base (isIntegration=false)
441
+ * - has ticket_id → always aggregate onto the ticket integration branch regardless of task count (isIntegration=true)
442
+ * Tasks under a ticket always go through the integration branch even for a single task (always return to base via a PR).
443
+ */
444
+ function decideTarget(cdb, item) {
445
+ if (!item.taskId)
446
+ return { branch: null, isIntegration: false, ticketId: null };
447
+ const row = cdb.db
448
+ .prepare(`SELECT ticket_id FROM tasks WHERE id = ?`)
449
+ .get(item.taskId);
450
+ const ticketId = row?.ticket_id ?? null;
451
+ if (!ticketId)
452
+ return { branch: null, isIntegration: false, ticketId: null };
453
+ // With a ticket_id, aggregate onto the integration branch regardless of the sibling count (the
454
+ // degenerate "direct merge to base" special case is dropped).
455
+ const existing = cdb.db
456
+ .prepare(`SELECT integration_branch FROM tickets WHERE id = ?`)
457
+ .get(ticketId);
458
+ const intBranch = existing?.integration_branch ?? `lockage/ticket/${short(ticketId)}`;
459
+ return { branch: intBranch, isIntegration: true, ticketId };
460
+ }
461
+ /**
462
+ * §4.5 ensureIntegrationBranch — lazily create the ticket integration branch (move git out of the DB transaction).
463
+ * (1) CAS integration_branch NULL→value in the DB (BEGIN IMMEDIATE, first wins). Only the winner proceeds to (2).
464
+ * (2) Idempotently create the git branch outside the transaction (a show-ref guard does nothing if it exists).
465
+ * The gap between (1) and (2) (reserved but no real branch) is filled on the next call by the show-ref guard.
466
+ */
467
+ function ensureIntegrationBranch(cdb, ticketId, repo, baseBranch) {
468
+ const db = cdb.db;
469
+ const intended = `lockage/ticket/${short(ticketId)}`;
470
+ // (1) Reserve the name (a short CAS; git is not called)
471
+ let branchName = intended;
472
+ db.exec('BEGIN IMMEDIATE');
473
+ try {
474
+ const cur = db
475
+ .prepare(`SELECT integration_branch FROM tickets WHERE id = ?`)
476
+ .get(ticketId);
477
+ if (cur?.integration_branch) {
478
+ branchName = cur.integration_branch; // already reserved by another worker
479
+ }
480
+ else {
481
+ db.prepare(`UPDATE tickets SET integration_branch = ? WHERE id = ? AND integration_branch IS NULL`).run(intended, ticketId);
482
+ }
483
+ db.exec('COMMIT');
484
+ }
485
+ catch (e) {
486
+ try {
487
+ db.exec('ROLLBACK');
488
+ }
489
+ catch {
490
+ /* already closed */
491
+ }
492
+ throw e;
493
+ }
494
+ // (2) Idempotently create the git branch outside the transaction (show-ref guard)
495
+ let exists = false;
496
+ try {
497
+ git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]);
498
+ exists = true;
499
+ }
500
+ catch {
501
+ exists = false;
502
+ }
503
+ if (!exists) {
504
+ git(repo, ['branch', branchName, baseBranch]);
505
+ }
506
+ return branchName;
507
+ }
508
+ /**
509
+ * §4.3 tryEmitTicketReady — if all tasks under the ticket are done, post ticket_ready to the board (idempotent).
510
+ * The NULL→value CAS on tickets.ticket_ready_at (BEGIN IMMEDIATE, first wins) makes it exactly once even across processes.
511
+ * Called from both done-confirmation paths (this process's merge / another process's follow-up).
512
+ */
513
+ function tryEmitTicketReady(cdb, ticketId) {
514
+ if (!ticketId)
515
+ return;
516
+ const db = cdb.db;
517
+ let shouldEmit = false;
518
+ db.exec('BEGIN IMMEDIATE');
519
+ try {
520
+ const remaining = db
521
+ .prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ticket_id = ? AND status != 'done'`)
522
+ .get(ticketId);
523
+ if (remaining.n === 0) {
524
+ const info = db
525
+ .prepare(`UPDATE tickets SET ticket_ready_at = ? WHERE id = ? AND ticket_ready_at IS NULL`)
526
+ .run(unixNow(), ticketId);
527
+ if (info.changes > 0)
528
+ shouldEmit = true; // only the NULL→value CAS winner emits
529
+ }
530
+ db.exec('COMMIT');
531
+ }
532
+ catch (e) {
533
+ try {
534
+ db.exec('ROLLBACK');
535
+ }
536
+ catch {
537
+ /* already closed */
538
+ }
539
+ throw e;
540
+ }
541
+ if (shouldEmit) {
542
+ // After the CAS succeeds, outside the transaction (postBoard opens its own BEGIN IMMEDIATE, so avoid nesting).
543
+ postBoard(cdb, {
544
+ agentId: 'system',
545
+ kind: 'ticket_ready',
546
+ body: `ticket ${ticketId}: all tasks done. ready to open a PR`,
547
+ refTicketId: ticketId,
548
+ });
549
+ }
550
+ }
551
+ /**
552
+ * §4.6 is_small_task — is it a "small task safe to merge immediately" (the 1-C decision)?
553
+ * Phase 1 default policy (conservative): only merge immediately when there is no divergence and zero conflict candidates.
554
+ * (a) no parent ticket (no ticket_id). With a ticket_id it is false regardless of task count
555
+ * (= never take 1-C; always aggregate onto the integration branch via 1-B and always return to base via a PR), and
556
+ * (b) base_branch has not advanced past my branch point (mainline unchanged = zero divergence)
557
+ * If neither holds, route it to 1-B. The real conflict decision is finalized in try_immediate_merge.
558
+ */
559
+ export function isSmallTask(cdb, item) {
560
+ // (a) leaf check: with a ticket_id it is always non-leaf (= never 1-C). Only proceed to (b) when absent.
561
+ if (item.taskId) {
562
+ const row = cdb.db
563
+ .prepare(`SELECT ticket_id FROM tasks WHERE id = ?`)
564
+ .get(item.taskId);
565
+ const ticketId = row?.ticket_id ?? null;
566
+ if (ticketId)
567
+ return false; // tasks under a ticket always go via the integration branch (1-B)
568
+ }
569
+ // (b) base-not-advanced check: if merge-base(base, branch) == base's tip, the mainline has not advanced since the branch point
570
+ try {
571
+ const baseBranch = resolveBaseBranch(item.repo);
572
+ const base = git(item.repo, ['merge-base', baseBranch, item.branch]);
573
+ const baseTip = git(item.repo, ['rev-parse', baseBranch]);
574
+ return base === baseTip;
575
+ }
576
+ catch {
577
+ return false; // if base resolution fails, conservatively route to 1-B
578
+ }
579
+ }
580
+ /**
581
+ * §4.6 try_immediate_merge — 1-C merge-and-discard immediately.
582
+ * Runs the same 3-way precheck as the serial gate "on the spot for just one item". If there are zero
583
+ * conflict candidates, merge immediately, discard the worktree immediately, and set the task done. If
584
+ * there are candidates, return ok:false and demote to 1-B.
585
+ * @returns ok = merge success, wake_targets = peers woken by the unblock (empty when ok:false).
586
+ */
587
+ export function tryImmediateMerge(cdb, item) {
588
+ const contested = threeWayPrecheck(cdb, item);
589
+ if (contested.length > 0)
590
+ return { ok: false, wake_targets: [] }; // divergence/conflict candidates → to 1-B
591
+ const baseBranch = resolveBaseBranch(item.repo);
592
+ // On git failure safeMerge discards the temporary worktree and restores the original state (high-4:
593
+ // leave no partial state). The merge runs on the temporary worktree, so the main working tree is untouched (Issue #25).
594
+ const res = safeMerge(item.repo, baseBranch, item.branch, '1-C');
595
+ if (!res.ok) {
596
+ // The immediate merge actually failed (a real conflict that slipped past the precheck, etc.). Do not
597
+ // discard the worktree; demote to 1-B and leave the serial gate to terminalize needs_review + notify.
598
+ return { ok: false, wake_targets: [] };
599
+ }
600
+ clearDirty(cdb, item.agentId, item.worktreePath);
601
+ // Merge succeeded → set the task done and wake the assignees of dependent downstream tasks.
602
+ const fin = finalizeMergedTask(cdb, item.agentId, item.taskId ?? null);
603
+ if (!fin.completed) {
604
+ // mid-C (1-C divergence): merged into the mainline, but completeTask's CAS mismatched so it cannot be
605
+ // set to done (15-minute lease sweep reopened it, reassigned, etc.). Discarding the worktree here
606
+ // would leave "merged into mainline, task not done, worktree gone" with no auto-recovery path. Keep
607
+ // the worktree (keepBranch:true) to preserve a clue for manual recovery.
608
+ return { ok: true, wake_targets: fin.woke };
609
+ }
610
+ // 1-C discards immediately (keep_branch=false). On remove failure, releaseWorktree swallows it and returns removed:false (mid-4).
611
+ releaseWorktree(cdb, {
612
+ agentId: item.agentId,
613
+ worktreePath: item.worktreePath,
614
+ repoPath: item.repo,
615
+ keepBranch: false,
616
+ });
617
+ return { ok: true, wake_targets: fin.woke };
618
+ }
619
+ /**
620
+ * §4.6 on_task_done — the entry point of the task_done tool (FR-1.6.3).
621
+ *
622
+ * Input validation (misuse prevention; stops accidents where git checkout/merge runs on the main working tree):
623
+ * - no_active_worktree if there is no active worktree assignment for (agent_id, worktree_path)
624
+ * - branch_is_base if branch equals the base branch (dev/stg/main/master)
625
+ * - when task_id is given, not_assignee if the caller is not the assignee (another agent owns it, or
626
+ * the worktree assignment is not tied to that task and is not eligible for rescue). If the
627
+ * lease-expiry rescue re-claim lost a race and the task is still open and unassigned, lease_expired
628
+ *
629
+ * After validation passes: a small task (zero divergence, zero conflict candidates) is merged immediately
630
+ * via try_immediate_merge (1-C). If there is divergence/conflict candidates, enqueue it on the serial gate
631
+ * via enqueue_merge and drive it with process_merge_queue (1-B). If the immediate merge hits candidates, it
632
+ * demotes to 1-B (fall-through). 1-B is always the safety net. On a successful merge, set the task done
633
+ * (finalizeMergedTask) and wake dependent downstream assignees.
634
+ */
635
+ export function onTaskDone(cdb, item) {
636
+ const taskId = item.taskId ?? null;
637
+ // 1) Is there an active worktree assignment (the core check that prevents a direct merge on the main
638
+ // tree)? Also fetch the worktree row's repo_path. This is the source of truth for the merge's git -C.
639
+ // Read task_id too: used in 3) below to decide whether the lease-expired owner is eligible for rescue
640
+ // (whether this worktree assignment is tied to that task). The repo_path resolution logic is unchanged.
641
+ const wt = cdb.db
642
+ .prepare(`SELECT repo_path, task_id FROM worktree_assignments
643
+ WHERE agent_id = ? AND worktree_path = ? AND status = 'active' LIMIT 1`)
644
+ .get(item.agentId, item.worktreePath);
645
+ if (!wt) {
646
+ return {
647
+ ok: false,
648
+ reason: 'no_active_worktree',
649
+ detail: `no active worktree assignment for agent='${item.agentId}' path='${item.worktreePath}'`,
650
+ };
651
+ }
652
+ // Use the worktree row's repo_path as the source of truth for the repo's FS root (the repo argument the
653
+ // caller passes may be an identifier and breaks when used for git -C — bug 2 found during the smoke test).
654
+ // Old DB rows with NULL repo_path fall back to the repo argument for backward compatibility. All
655
+ // subsequent processing uses this resolved item.
656
+ const resolved = wt.repo_path != null && wt.repo_path !== '' ? { ...item, repo: wt.repo_path } : item;
657
+ item = resolved;
658
+ // 2) Is branch not the same as the base branch (do not target main etc. for a direct merge)?
659
+ let baseBranch;
660
+ try {
661
+ baseBranch = resolveBaseBranch(item.repo);
662
+ }
663
+ catch {
664
+ baseBranch = 'main';
665
+ }
666
+ if (item.branch === baseBranch) {
667
+ return {
668
+ ok: false,
669
+ reason: 'branch_is_base',
670
+ detail: `branch '${item.branch}' is the base branch; a worktree branch is required`,
671
+ };
672
+ }
673
+ // 3) When task_id is given, is the assignee the caller?
674
+ // Proposal 3 (Issue #51)'s lease-expiry rescue goes here. FEAT-002's piggyback extension removed the
675
+ // main cause, but if the owner called no lockage tool for a long time, the sweep can still reopen it.
676
+ // When that owner (= whose worktree assignment is tied to that task and remains active) calls
677
+ // task_done, if no one has re-claimed it, the owner re-claims it and continues. If another agent has
678
+ // already claimed it, return not_assignee as before and prevent double work (no stealing).
679
+ //
680
+ // Approach: make "rescue and re-claim it yourself" the main path (of Proposal 3's two options, take
681
+ // the one that transparently succeeds on the server side). On a successful rescue it is not
682
+ // distinguished from a normal task_done. On a failed rescue, split the two cases where the caller's
683
+ // next move differs into two reasons. If merged into one, the caller cannot tell whether it may retry
684
+ // the re-claim in case (A).
685
+ // (A) the caller is not the assignee (another agent owns it, or the assignment is not tied to this
686
+ // task and is not eligible for rescue) → not_assignee (repeating the same call will not go through)
687
+ // (B) the re-claim lost the CAS, but the task is still open and unassigned → lease_expired (safe to retry)
688
+ if (taskId) {
689
+ const row = cdb.db
690
+ .prepare(`SELECT assignee, status, version FROM tasks WHERE id = ?`)
691
+ .get(taskId);
692
+ if (row && row.assignee !== item.agentId) {
693
+ // Decide the rescuable case:
694
+ // (a) the task has status='open' and assignee IS NULL (= the sweep reopened it on lease expiry and
695
+ // no one has re-claimed it yet), and
696
+ // (b) the active worktree assignment fetched at the top is tied to this task_id (= continuation of
697
+ // the owner's work).
698
+ // Only when both hold does the owner re-claim it. Otherwise (another agent already holds it
699
+ // in_progress, etc.) return not_assignee as before.
700
+ // An assignment with NULL task_id (a leftover from the old path that called assign_worktree without
701
+ // passing task_id) does not satisfy (b), so it is not rescued. When the lease expires the assignee is
702
+ // cleared, so the only remaining evidence that the caller is this task's owner is the assignment's
703
+ // task_id link. With NULL there is no such evidence, and rescuing could pull unrelated work in as this
704
+ // task. In that case too return not_assignee, so the owner re-claims it first and then calls task_done.
705
+ const rescuable = row.status === 'open' && row.assignee === null && wt.task_id === taskId;
706
+ if (rescuable) {
707
+ // claimTask is expected_version + CAS (BEGIN IMMEDIATE). Pass the version read here so it detects
708
+ // the "another agent cut in between the read and the claim" race. If it fails on a race, do not
709
+ // rescue; reject (no stealing = prevent double work).
710
+ // Note: claimTask opens its own BEGIN IMMEDIATE, so do not nest another transaction here.
711
+ const claimed = claimTask(cdb, taskId, item.agentId, row.version);
712
+ if (!claimed.ok) {
713
+ // Dispatch by the state at the moment of failure (the current that claimTask re-read within the
714
+ // same transaction). If it is still open and unassigned, only the version moved — a transient
715
+ // race — so lease_expired (retryable). If another agent owns it, the task is gone, etc., not_assignee.
716
+ const current = claimed.reason === 'conflict' ? claimed.current : undefined;
717
+ if (current && current.status === 'open' && current.assignee === null) {
718
+ return {
719
+ ok: false,
720
+ reason: 'lease_expired',
721
+ detail: `task '${taskId}' lease expired and re-claim lost a race; it is still open and unassigned, retry task_done`,
722
+ };
723
+ }
724
+ return {
725
+ ok: false,
726
+ reason: 'not_assignee',
727
+ detail: `task '${taskId}' was reassigned before re-claim (lease expired); assigned to '${current?.assignee ?? '(none)'}', not '${item.agentId}'`,
728
+ };
729
+ }
730
+ // Rescue succeeded → the assignee is back to the owner. Continue as a normal task_done from here.
731
+ }
732
+ else {
733
+ return {
734
+ ok: false,
735
+ reason: 'not_assignee',
736
+ detail: `task '${taskId}' is assigned to '${row.assignee ?? '(none)'}', not '${item.agentId}'`,
737
+ };
738
+ }
739
+ }
740
+ }
741
+ // Validation passed → decide the aggregation target (A-2). With a ticket_id, always aggregate onto the
742
+ // ticket integration branch. Only minor tasks without a ticket_id merge directly to base (no targetBranch).
743
+ const target = decideTarget(cdb, item);
744
+ if (target.isIntegration) {
745
+ const intBranch = ensureIntegrationBranch(cdb, target.ticketId, item.repo, baseBranch);
746
+ item = { ...item, targetBranch: intBranch };
747
+ }
748
+ // Merge
749
+ // Note: with a ticket_id, always aggregate onto the integration branch. isSmallTask's (a) check returns
750
+ // false and does not take 1-C (always goes to 1-B below). Only minor tasks without a ticket_id take
751
+ // 1-C and merge directly to base.
752
+ if (isSmallTask(cdb, item)) {
753
+ const r = tryImmediateMerge(cdb, item);
754
+ if (r.ok) {
755
+ return {
756
+ ok: true,
757
+ merged: [{ task_id: taskId, branch: item.branch, path: '1-C' }],
758
+ needs_review: [],
759
+ wake_targets: r.wake_targets,
760
+ };
761
+ }
762
+ // the immediate merge hit conflict candidates → demote to 1-B (fall-through)
763
+ }
764
+ // mid (2-process miss): keep the merge_id we enqueued. processMergeQueue silently breaks on the side that
765
+ // sees inflight, so if another process handles our item it does not appear in r. Avoid over-engineering;
766
+ // limit it to "fill the final state of our own merge_id into the result" (hand-off policy).
767
+ const { merge_id } = enqueueMerge(cdb, item);
768
+ const r = processMergeQueue(cdb);
769
+ const merged = [...r.merged];
770
+ const needsReview = [...r.needs_review];
771
+ const wakeTargets = new Set(r.wake_targets);
772
+ // If our merge_id is not included in r (= another process handled it, or it was broken out on inflight),
773
+ // fetch the row's current state and reflect it into the result. If state=queued/merging it is "still
774
+ // processing", so leave it untouched.
775
+ const mine = cdb.db
776
+ .prepare(`SELECT * FROM merge_queue WHERE id = ?`)
777
+ .get(merge_id);
778
+ if (mine) {
779
+ const alreadyMerged = merged.some((m) => m.task_id === mine.task_id && m.branch === mine.branch);
780
+ const alreadyNeedsReview = needsReview.some((n) => n.task_id === mine.task_id && n.branch === mine.branch);
781
+ if (mine.state === 'merged' && !alreadyMerged) {
782
+ merged.push({ task_id: mine.task_id, branch: mine.branch, path: '1-B' });
783
+ // A-3 path 2 (miss 2): if another process merged our task, the ticket_ready check inside
784
+ // processMergeQueue (path 1) did not run. Fill it here. Only when aggregating (target_branch present).
785
+ // tryEmitTicketReady is idempotent via the ticket_ready_at CAS, so it does not double-emit with path 1.
786
+ if (mine.target_branch) {
787
+ const ticketRow = mine.task_id
788
+ ? cdb.db
789
+ .prepare(`SELECT ticket_id FROM tasks WHERE id = ?`)
790
+ .get(mine.task_id)
791
+ : undefined;
792
+ tryEmitTicketReady(cdb, ticketRow?.ticket_id ?? null);
793
+ }
794
+ }
795
+ else if (mine.state === 'needs_review' && !alreadyNeedsReview) {
796
+ let contested = [];
797
+ let error;
798
+ if (mine.precheck) {
799
+ try {
800
+ const p = JSON.parse(mine.precheck);
801
+ contested = p.contested ?? [];
802
+ error = p.error;
803
+ }
804
+ catch {
805
+ /* if precheck cannot be parsed, leave it empty */
806
+ }
807
+ }
808
+ needsReview.push({ task_id: mine.task_id, branch: mine.branch, contested, error });
809
+ wakeTargets.add(mine.agent_id);
810
+ }
811
+ // queued / merging are not finalized yet (another process is handling them), so do not fill them.
812
+ }
813
+ return {
814
+ ok: true,
815
+ merged,
816
+ needs_review: needsReview,
817
+ wake_targets: [...wakeTargets],
818
+ };
819
+ }
820
+ /** The merge queue listing in needs_review / all states (for observation). */
821
+ export function listMergeQueue(cdb, state) {
822
+ if (state) {
823
+ return cdb.db
824
+ .prepare(`SELECT * FROM merge_queue WHERE state = ? ORDER BY seq ASC`)
825
+ .all(state);
826
+ }
827
+ return cdb.db
828
+ .prepare(`SELECT * FROM merge_queue ORDER BY seq ASC`)
829
+ .all();
830
+ }
831
+ //# sourceMappingURL=merge.js.map