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.
- package/LICENSE +21 -0
- package/README.md +355 -2
- package/dist/db/index.d.ts +52 -0
- package/dist/db/index.js +238 -0
- package/dist/db/schema.sql +238 -0
- package/dist/hook/clients.d.ts +56 -0
- package/dist/hook/clients.js +258 -0
- package/dist/hook/context.d.ts +22 -0
- package/dist/hook/context.js +40 -0
- package/dist/hook/dispatch.d.ts +21 -0
- package/dist/hook/dispatch.js +212 -0
- package/dist/hook/guard.d.ts +34 -0
- package/dist/hook/guard.js +106 -0
- package/dist/hook/repo.d.ts +40 -0
- package/dist/hook/repo.js +173 -0
- package/dist/hook/setup-kiro.d.ts +30 -0
- package/dist/hook/setup-kiro.js +118 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.js +694 -0
- package/dist/launcher/client.d.ts +22 -0
- package/dist/launcher/client.js +57 -0
- package/dist/launcher/publish-adapter.d.ts +88 -0
- package/dist/launcher/publish-adapter.js +233 -0
- package/dist/launcher/publish-cli.d.ts +2 -0
- package/dist/launcher/publish-cli.js +123 -0
- package/dist/launcher/publish.d.ts +140 -0
- package/dist/launcher/publish.js +522 -0
- package/dist/lockage/board.d.ts +247 -0
- package/dist/lockage/board.js +407 -0
- package/dist/lockage/claim.d.ts +307 -0
- package/dist/lockage/claim.js +580 -0
- package/dist/lockage/files.d.ts +229 -0
- package/dist/lockage/files.js +403 -0
- package/dist/lockage/git.d.ts +92 -0
- package/dist/lockage/git.js +183 -0
- package/dist/lockage/globs.d.ts +26 -0
- package/dist/lockage/globs.js +125 -0
- package/dist/lockage/merge.d.ts +132 -0
- package/dist/lockage/merge.js +831 -0
- package/dist/lockage/publish-lock.d.ts +38 -0
- package/dist/lockage/publish-lock.js +73 -0
- package/dist/lockage/tickets.d.ts +213 -0
- package/dist/lockage/tickets.js +340 -0
- package/dist/lockage/worktree.d.ts +212 -0
- package/dist/lockage/worktree.js +563 -0
- package/package.json +43 -4
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { ulid } from 'ulid';
|
|
4
|
+
import { unixNow } from '../db/index.js';
|
|
5
|
+
import { git, resolveBaseBranch, resolveRefSha, safeSlug, short } from './git.js';
|
|
6
|
+
/**
|
|
7
|
+
* #36 / #41: a helper that realpath-normalizes a path to absorb symlink-resolution differences.
|
|
8
|
+
*
|
|
9
|
+
* `git worktree list --porcelain` may return a normalized path with symlinks resolved (e.g. on macOS
|
|
10
|
+
* `/var/...` → `/private/var/...`). On the other hand, the ledger (worktree_assignments.worktree_path)
|
|
11
|
+
* used to hold the path assignWorktree built with `join(...)` (a path that does not resolve symlinks).
|
|
12
|
+
*
|
|
13
|
+
* The #41 fix made assignWorktree normalize with normalizePath before saving, but existing ledger rows
|
|
14
|
+
* (saved un-normalized before #41) remain with symlinks unresolved, so normalization on the read side of
|
|
15
|
+
* reclaimOrphanWorktrees is still needed as a "backward-compatibility layer for old rows". We do not run a
|
|
16
|
+
* migration to bulk realpath-rewrite existing ledger rows. Reasons:
|
|
17
|
+
* (1) rows for already-deleted worktrees make realpathSync fail.
|
|
18
|
+
* (2) it would require touching released history rows too.
|
|
19
|
+
* (3) normalization on the read side (reclaimOrphanWorktrees) already absorbs matching old rows.
|
|
20
|
+
*
|
|
21
|
+
* realpathSync throws when, e.g., the target does not exist, so in that case best-effort return the input
|
|
22
|
+
* path as-is (cannot normalize = fall back to the original string comparison). In assignWorktree's
|
|
23
|
+
* pre-save normalization (#41) it is called "after the physical worktree is created with git worktree
|
|
24
|
+
* add", so in the normal path it always resolves. In reclaimOrphanWorktrees' read-side normalization
|
|
25
|
+
* (#36), the ledger path of an already-deleted worktree may fall back, in which case we warn via console.error.
|
|
26
|
+
*/
|
|
27
|
+
function normalizePath(p) {
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(p);
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
// When the target does not exist / permission is insufficient, etc., we cannot normalize, so return
|
|
33
|
+
// the input as-is (best effort). But a silent fallback would mean that if only one side fails to
|
|
34
|
+
// normalize and an orphan is misidentified, no one notices in operation. Leave a warning for which
|
|
35
|
+
// path realpathSync failed on (this codebase's convention is to emit diagnostic logs to console.error).
|
|
36
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
37
|
+
console.error(`[worktree] normalizePath: realpathSync failed, using the input path as-is (path=${p}): ${detail}`);
|
|
38
|
+
return p;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether the branch already exists in the repo (local ref). */
|
|
42
|
+
function branchExists(repoPath, branch) {
|
|
43
|
+
try {
|
|
44
|
+
git(repoPath, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* §4.5 Automatic worktree assignment.
|
|
53
|
+
* Physically isolate each agent with a git worktree (the core that makes "two agents writing the same
|
|
54
|
+
* file at once" physically impossible). The advisory claim is the coordination layer on top of this.
|
|
55
|
+
*
|
|
56
|
+
* - Reuse an existing active assignment if present (idempotent).
|
|
57
|
+
* - The branch name/path are deterministic (lockage/<agent>/<slug>). On reconnect you return to the same worktree.
|
|
58
|
+
* - The UNIQUE constraint on worktree_path guarantees "1 worktree = 1 agent" at the DB level.
|
|
59
|
+
*
|
|
60
|
+
* #41 fix: worktree_path is normalized with realpathSync after the physical worktree is created and then
|
|
61
|
+
* saved to the ledger, and the return value is the normalized value too. Downstream exact-match
|
|
62
|
+
* comparisons on worktree_path (releaseWorktree, merge.ts's onTaskDone, files.ts's publish_dirty,
|
|
63
|
+
* claim.ts's handoff) all have the structure of either "the caller passes the worktree_path that
|
|
64
|
+
* assignWorktree returned as the key" or "use the worktree_path read from the ledger", so once the saved
|
|
65
|
+
* value is normalized they naturally match normalized-against-normalized and no new normalization call is
|
|
66
|
+
* needed (fixing #36's one-sided fallback mismatch on the write side).
|
|
67
|
+
*
|
|
68
|
+
* @param repoPath the filesystem root of the target repo (the multi-repo `git -C` target).
|
|
69
|
+
*/
|
|
70
|
+
export function assignWorktree(cdb, args) {
|
|
71
|
+
const db = cdb.db;
|
|
72
|
+
const now = unixNow();
|
|
73
|
+
const baseRef = args.baseRef ?? resolveBaseBranch(args.repoPath);
|
|
74
|
+
// Best-effort resolve the real existing SHA of the branch point (FEAT-002). Used later to compute the
|
|
75
|
+
// diff from base (git diff --name-only <base_sha>...HEAD); recorded only at new assignment time. When it
|
|
76
|
+
// cannot be resolved (ref missing, empty repo, etc.) it is null → base_sha is saved as NULL and the diff
|
|
77
|
+
// computation falls back to status only when base_sha is absent. On the idempotent-reuse path of an
|
|
78
|
+
// existing active assignment, base_sha is not overwritten (keep the first branch point).
|
|
79
|
+
const baseSha = resolveRefSha(args.repoPath, baseRef);
|
|
80
|
+
// Generate the branch name/path deterministically (avoid collisions). Because it includes the repo name
|
|
81
|
+
// + agent + task slug, only calls with the "same agent, same repo, same task" land on the same path;
|
|
82
|
+
// different repo / different task become different paths. The reuse decision keys on this deterministic
|
|
83
|
+
// path (keying on agent_id alone would return the first worktree even when a different repo or task is
|
|
84
|
+
// requested — wrong for multi-repo / multiple assigned tasks).
|
|
85
|
+
const slug = args.taskId ? short(args.taskId) : short(args.agentId);
|
|
86
|
+
const agentSlug = safeSlug(args.agentId);
|
|
87
|
+
const branch = `lockage/${agentSlug}/${slug}`;
|
|
88
|
+
// Dig a .lockage-worktrees/ under the repo's parent directory.
|
|
89
|
+
const repoParent = dirname(args.repoPath);
|
|
90
|
+
const repoName = basename(args.repoPath);
|
|
91
|
+
const path = join(repoParent, '.lockage-worktrees', `${repoName}-${agentSlug}-${slug}`);
|
|
92
|
+
// 1) Reuse an existing active assignment if present (idempotent). Key on the active row of this deterministic path.
|
|
93
|
+
// #41: the ledger now saves normalized paths, but old ledger rows (saved un-normalized before #41) remain
|
|
94
|
+
// as the original join() path. Because the deterministic path for the same agent/repo/task matches as a
|
|
95
|
+
// string, the reuse decision picks up both old and new rows whether on path (un-normalized) or
|
|
96
|
+
// canonicalPath (normalized). However, for a new assignment where the physical worktree does not exist
|
|
97
|
+
// yet, realpathSync fails, so here we query with the un-normalized path and re-check with the normalized
|
|
98
|
+
// path after the physical worktree is created.
|
|
99
|
+
const existing = db
|
|
100
|
+
.prepare(`SELECT * FROM worktree_assignments
|
|
101
|
+
WHERE agent_id = ? AND worktree_path = ? AND status = 'active' LIMIT 1`)
|
|
102
|
+
.get(args.agentId, path);
|
|
103
|
+
if (existing) {
|
|
104
|
+
// #41 follow-up (review finding #2): when reusing an old ledger row (saved un-normalized before #41),
|
|
105
|
+
// if the return value stays un-normalized, downstream exact-match comparisons on worktree_path diverge
|
|
106
|
+
// between the "normalized new row" and the "un-normalized old row", leaving the theoretical room for a
|
|
107
|
+
// normalized and an un-normalized row to coexist for the same worktree (a re-INSERT after release
|
|
108
|
+
// slips past UNIQUE as a different string). To close this, lazily normalize the saved value on reuse too.
|
|
109
|
+
//
|
|
110
|
+
// Safety: normalizePath only folds to the real path when the target physically exists, and returns the
|
|
111
|
+
// input as-is when it does not (= an old row for a deleted worktree). Therefore:
|
|
112
|
+
// - an old row whose physical worktree remains is rewritten to normalized here and converges
|
|
113
|
+
// (thereafter all comparisons for that worktree match normalized-against-normalized).
|
|
114
|
+
// - an old row for a deleted worktree returns the original value on realpath failure (keeps backward compatibility).
|
|
115
|
+
// If the UPDATE would violate the partial UNIQUE (idx_wt_path_active) (= the rare case where a
|
|
116
|
+
// normalized active row for the same real path already exists), give up the rewrite and return the
|
|
117
|
+
// original saved value as-is (fail-safe).
|
|
118
|
+
const reusedCanonical = normalizePath(existing.worktree_path);
|
|
119
|
+
if (reusedCanonical !== existing.worktree_path) {
|
|
120
|
+
try {
|
|
121
|
+
db.prepare(`UPDATE worktree_assignments SET worktree_path = ? WHERE id = ?`).run(reusedCanonical, existing.id);
|
|
122
|
+
return {
|
|
123
|
+
worktree_path: reusedCanonical,
|
|
124
|
+
branch: existing.branch,
|
|
125
|
+
reused: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// If it cannot be rewritten due to a UNIQUE collision etc., return the old value as-is (status quo = fail-safe).
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
worktree_path: existing.worktree_path,
|
|
134
|
+
branch: existing.branch,
|
|
135
|
+
reused: true,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// 2) Physically create the worktree (if it exists, skip creation and only record).
|
|
139
|
+
// Pass the original pre-normalization path to git worktree add as-is. normalizePath is used only for
|
|
140
|
+
// matching/saving; passing a realpath to the git command could cause other problems depending on the
|
|
141
|
+
// environment, so follow the existing reclaimOrphanWorktrees convention of "pass the original p to remove" (#41).
|
|
142
|
+
if (!existsSync(path)) {
|
|
143
|
+
if (branchExists(args.repoPath, branch)) {
|
|
144
|
+
// Add an existing branch without -b (reconnect / continuation of previous work)
|
|
145
|
+
git(args.repoPath, ['worktree', 'add', path, branch]);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
git(args.repoPath, ['worktree', 'add', '-b', branch, path, baseRef]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// #41 fix: with the physical worktree present, realpathSync-normalize and make the ledger-saved value and
|
|
152
|
+
// the return value normalized. This makes downstream exact-match comparisons on worktree_path
|
|
153
|
+
// (releaseWorktree, merge.ts's onTaskDone, files.ts's publish_dirty, claim.ts's handoff) naturally match
|
|
154
|
+
// normalized-against-normalized, fixing the "one-sided fallback mismatch" raised in #36 on the write
|
|
155
|
+
// side. realpathSync throws when the target does not exist, so it must be called after git worktree add
|
|
156
|
+
// creates the physical directory.
|
|
157
|
+
const canonicalPath = normalizePath(path);
|
|
158
|
+
// #41: do the reuse decision with the normalized path too. If an old ledger row is saved with an
|
|
159
|
+
// un-normalized path, the SELECT above on path picks it up, while a row newly saved with a normalized
|
|
160
|
+
// path can differ as a string (symlink environments). Conversely, querying with the normalized path picks
|
|
161
|
+
// up rows saved since #41. For the same agent/repo/task's deterministic path, many normal environments
|
|
162
|
+
// have path === canonicalPath, but to safely handle the symlink case we check both.
|
|
163
|
+
if (canonicalPath !== path) {
|
|
164
|
+
const existingCanonical = db
|
|
165
|
+
.prepare(`SELECT * FROM worktree_assignments
|
|
166
|
+
WHERE agent_id = ? AND worktree_path = ? AND status = 'active' LIMIT 1`)
|
|
167
|
+
.get(args.agentId, canonicalPath);
|
|
168
|
+
if (existingCanonical) {
|
|
169
|
+
return {
|
|
170
|
+
worktree_path: existingCanonical.worktree_path,
|
|
171
|
+
branch: existingCanonical.branch,
|
|
172
|
+
reused: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// 3) Record. The UNIQUE constraint on worktree_path rejects double assignment at the DB.
|
|
177
|
+
// BEGIN IMMEDIATE serializes numbering through INSERT (avoids the concurrent-assignment race).
|
|
178
|
+
// #41: the saved value uses the normalized canonicalPath. The return value is the same.
|
|
179
|
+
db.exec('BEGIN IMMEDIATE');
|
|
180
|
+
try {
|
|
181
|
+
// Re-check in case another path recorded the same worktree just before (query with the normalized path)
|
|
182
|
+
const racer = db
|
|
183
|
+
.prepare(`SELECT * FROM worktree_assignments WHERE worktree_path = ? AND status = 'active'`)
|
|
184
|
+
.get(canonicalPath);
|
|
185
|
+
if (racer) {
|
|
186
|
+
db.exec('ROLLBACK');
|
|
187
|
+
return {
|
|
188
|
+
worktree_path: racer.worktree_path,
|
|
189
|
+
branch: racer.branch,
|
|
190
|
+
reused: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
db.prepare(`INSERT INTO worktree_assignments
|
|
194
|
+
(id, agent_id, worktree_path, repo_path, branch, base_sha, task_id, status, created_at, released_at, host_id, origin)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, NULL, ?, 'local')`).run(ulid(), args.agentId, canonicalPath, args.repoPath, branch, baseSha, args.taskId ?? null, now, cdb.hostId);
|
|
196
|
+
db.exec('COMMIT');
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
try {
|
|
200
|
+
db.exec('ROLLBACK');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* already closed */
|
|
204
|
+
}
|
|
205
|
+
throw e;
|
|
206
|
+
}
|
|
207
|
+
return { worktree_path: canonicalPath, branch, reused: false };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Infer why `git worktree remove` was refused from the worktree's working-tree state.
|
|
211
|
+
*
|
|
212
|
+
* Classify by the leading 2 characters of each `git status --porcelain` line:
|
|
213
|
+
* - starts with `??` = untracked file.
|
|
214
|
+
* - otherwise (` M`/`M `/`A `/`D `, etc.) = a tracked uncommitted diff.
|
|
215
|
+
* If there is even one tracked diff, prefer 'dirty' (because the information lost is larger); if only
|
|
216
|
+
* untracked, return 'untracked'. If fetching the status itself fails, treat it as 'dirty' as the cause is
|
|
217
|
+
* unknown (fail-safe: keep it active so it can be reclaimed).
|
|
218
|
+
*/
|
|
219
|
+
function classifyRemoveFailure(worktreePath) {
|
|
220
|
+
let porcelain;
|
|
221
|
+
try {
|
|
222
|
+
porcelain = git(worktreePath, ['status', '--porcelain']);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// When even status cannot be fetched (abnormal), treat it as dirty (keep active, fail-safe).
|
|
226
|
+
return 'dirty';
|
|
227
|
+
}
|
|
228
|
+
const lines = porcelain.split('\n').filter((l) => l.length > 0);
|
|
229
|
+
let hasTracked = false;
|
|
230
|
+
let hasUntracked = false;
|
|
231
|
+
for (const line of lines) {
|
|
232
|
+
if (line.startsWith('??'))
|
|
233
|
+
hasUntracked = true;
|
|
234
|
+
else
|
|
235
|
+
hasTracked = true;
|
|
236
|
+
}
|
|
237
|
+
// Prefer 'dirty' if there is a tracked diff. If none and only untracked, 'untracked'.
|
|
238
|
+
if (hasTracked)
|
|
239
|
+
return 'dirty';
|
|
240
|
+
if (hasUntracked)
|
|
241
|
+
return 'untracked';
|
|
242
|
+
// remove was refused even though no diff is found (locked, etc.). Dirty on the fail-safe side.
|
|
243
|
+
return 'dirty';
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* §4.5 Worktree release.
|
|
247
|
+
* - keep_branch defaults to true (branch deletion is destructive, so only when explicitly false).
|
|
248
|
+
* - Runs `git worktree remove` (does not use --force because git refuses when there are uncommitted changes).
|
|
249
|
+
* - Only when remove succeeds does it set the ledger to released and clear that worktree's dirty set
|
|
250
|
+
* (§4.4 — cleared on mainline reflection or discard).
|
|
251
|
+
*
|
|
252
|
+
* The #33 fix: previously, even when remove was refused due to untracked files / uncommitted diffs
|
|
253
|
+
* (removed=false), the ledger was unconditionally flipped to released and dirty_sets was deleted. As a
|
|
254
|
+
* result, the physical worktree and its `git worktree list` registration remained while the active ledger
|
|
255
|
+
* row disappeared, becoming an "orphan" that does not appear in list_worktrees and cannot be re-released.
|
|
256
|
+
* This is changed to "only when removed=true, set the ledger to released and clear dirty_sets. When
|
|
257
|
+
* removed=false, leave the status active, touch neither the ledger nor dirty_sets, and put 'untracked' /
|
|
258
|
+
* 'dirty' in the returned reason so the caller can notice". Branch deletion (keep_branch=false) is also
|
|
259
|
+
* done only when removed=true (deleting the branch while the worktree remains breaks the worktree).
|
|
260
|
+
*/
|
|
261
|
+
export function releaseWorktree(cdb, args) {
|
|
262
|
+
const db = cdb.db;
|
|
263
|
+
const now = unixNow();
|
|
264
|
+
const keepBranch = args.keepBranch ?? true;
|
|
265
|
+
const row = db
|
|
266
|
+
.prepare(`SELECT * FROM worktree_assignments
|
|
267
|
+
WHERE worktree_path = ? AND agent_id = ? AND status = 'active'`)
|
|
268
|
+
.get(args.worktreePath, args.agentId);
|
|
269
|
+
if (!row)
|
|
270
|
+
return { ok: false, removed: false };
|
|
271
|
+
// Detach the physical worktree. If it does not exist, only update the record.
|
|
272
|
+
// When there are uncommitted changes `git worktree remove` refuses (do not use --force = avoid destruction).
|
|
273
|
+
// Swallow the failure and return removed:false + reason (do not let the exception escape).
|
|
274
|
+
let removed = false;
|
|
275
|
+
let reason;
|
|
276
|
+
if (existsSync(args.worktreePath)) {
|
|
277
|
+
try {
|
|
278
|
+
git(args.repoPath, ['worktree', 'remove', args.worktreePath]);
|
|
279
|
+
removed = true;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// Refused due to untracked files / uncommitted diffs → keep the physical worktree.
|
|
283
|
+
// Attach a reason so the caller can tell the cause (#33).
|
|
284
|
+
removed = false;
|
|
285
|
+
reason = classifyRemoveFailure(args.worktreePath);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// The directory is gone but git may still have debris, so prune.
|
|
290
|
+
// A missing directory means "the working tree is already gone" = treat as released and set removed=true.
|
|
291
|
+
try {
|
|
292
|
+
git(args.repoPath, ['worktree', 'prune']);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* best effort */
|
|
296
|
+
}
|
|
297
|
+
removed = true;
|
|
298
|
+
}
|
|
299
|
+
// When removed=false, touch neither the ledger, dirty_sets, nor the branch; leave it active (#33).
|
|
300
|
+
// Releasing again later (e.g. after stashing uncommitted diffs) can reclaim it.
|
|
301
|
+
if (!removed) {
|
|
302
|
+
return { ok: true, removed: false, reason };
|
|
303
|
+
}
|
|
304
|
+
// Delete the branch only when removed=true (deleting the branch while the worktree remains breaks it).
|
|
305
|
+
if (!keepBranch) {
|
|
306
|
+
try {
|
|
307
|
+
git(args.repoPath, ['branch', '-D', row.branch]);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
/* ignore when the branch is missing / checked out, etc. */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
db.exec('BEGIN IMMEDIATE');
|
|
314
|
+
try {
|
|
315
|
+
db.prepare(`UPDATE worktree_assignments SET status = 'released', released_at = ?
|
|
316
|
+
WHERE id = ?`).run(now, row.id);
|
|
317
|
+
// On discard, clear that worktree's dirty-set row (the discipline at the end of §4.5)
|
|
318
|
+
db.prepare(`DELETE FROM dirty_sets WHERE worktree_path = ?`).run(args.worktreePath);
|
|
319
|
+
db.exec('COMMIT');
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
try {
|
|
323
|
+
db.exec('ROLLBACK');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
/* already closed */
|
|
327
|
+
}
|
|
328
|
+
throw e;
|
|
329
|
+
}
|
|
330
|
+
return { ok: true, removed: true };
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* §4.5 addendum (#32 option 1): release in bulk the active worktree assignments tied to done tasks under
|
|
334
|
+
* the given ticket.
|
|
335
|
+
*
|
|
336
|
+
* Since #16 changed "tasks under a ticket are 1-B even for a single one", the 1-B path (processMergeQueue→
|
|
337
|
+
* finalizeMergedTask) did not release worktrees and reconcile did not touch worktrees either, so under
|
|
338
|
+
* ticket operation the worktrees and lockage/<agent>/<slug> branches of completed tasks kept piling up.
|
|
339
|
+
* Clean them up by calling this from reconcile's MERGED path (after confirming all tasks are done and the PR is merged).
|
|
340
|
+
*
|
|
341
|
+
* Conditions for release:
|
|
342
|
+
* - worktree_assignments.status = 'active'
|
|
343
|
+
* - task_id is not NULL (worktree_assignments.task_id can become NULL via ON DELETE SET NULL, but with
|
|
344
|
+
* NULL the ticket link cannot be traced, so exclude it).
|
|
345
|
+
* - that task_id's tasks.ticket_id matches the given ticket, and tasks.status = 'done'.
|
|
346
|
+
*
|
|
347
|
+
* For each target row call releaseWorktree keyed on (agent_id, worktree_path). Because the calling
|
|
348
|
+
* publisher needs to release worktrees of multiple agents under the ticket, use each assignment row's
|
|
349
|
+
* agent_id (not the publisher's own agent_id). Call it with keep_branch=false equivalent to also clean up
|
|
350
|
+
* the task branch (lockage/<agent>/<slug>) merged via the integration branch.
|
|
351
|
+
*
|
|
352
|
+
* By the FEAT-002 fix, releaseWorktree returns removed:false on remove failure (uncommitted diffs present)
|
|
353
|
+
* and leaves the ledger active, so a worktree not released here does not become an orphan; it remains
|
|
354
|
+
* active and can be reclaimed later (safe). Because releaseWorktree is idempotent on the presence of the
|
|
355
|
+
* active row, a double reconcile run does not double-release.
|
|
356
|
+
*
|
|
357
|
+
* @param ticketId the target ticket (matched against tasks.ticket_id).
|
|
358
|
+
* @param repoPath the FS root of the repo where the worktrees physically live (the `git -C` target). The
|
|
359
|
+
* assignment row's repo_path is preferred; this value is used as a fallback when it is
|
|
360
|
+
* absent (old DB row).
|
|
361
|
+
*/
|
|
362
|
+
export function releaseWorktreesForTicket(cdb, args) {
|
|
363
|
+
const db = cdb.db;
|
|
364
|
+
// keep_branch defaults to false (merged via the integration branch, so the task branch may be deleted too = #32 option 1).
|
|
365
|
+
const keepBranch = args.keepBranch ?? false;
|
|
366
|
+
// Enumerate active assignments tied to done tasks under the ticket. Rows with NULL task_id drop out of
|
|
367
|
+
// the JOIN (= excluded). tasks.status='done' avoids wrongly deleting worktrees of incomplete tasks.
|
|
368
|
+
const rows = db
|
|
369
|
+
.prepare(`SELECT wa.agent_id AS agent_id,
|
|
370
|
+
wa.worktree_path AS worktree_path,
|
|
371
|
+
wa.repo_path AS repo_path,
|
|
372
|
+
wa.task_id AS task_id
|
|
373
|
+
FROM worktree_assignments wa
|
|
374
|
+
JOIN tasks t ON t.id = wa.task_id
|
|
375
|
+
WHERE wa.status = 'active'
|
|
376
|
+
AND t.ticket_id = ?
|
|
377
|
+
AND t.status = 'done'
|
|
378
|
+
ORDER BY wa.created_at ASC`)
|
|
379
|
+
.all(args.ticketId);
|
|
380
|
+
const released = [];
|
|
381
|
+
let removedCount = 0;
|
|
382
|
+
for (const r of rows) {
|
|
383
|
+
// Old DB rows may have NULL repo_path, so use the repoPath argument as a fallback.
|
|
384
|
+
const repoPath = r.repo_path ?? args.repoPath;
|
|
385
|
+
const res = releaseWorktree(cdb, {
|
|
386
|
+
agentId: r.agent_id,
|
|
387
|
+
worktreePath: r.worktree_path,
|
|
388
|
+
repoPath,
|
|
389
|
+
keepBranch,
|
|
390
|
+
});
|
|
391
|
+
if (res.removed)
|
|
392
|
+
removedCount += 1;
|
|
393
|
+
released.push({
|
|
394
|
+
agent_id: r.agent_id,
|
|
395
|
+
worktree_path: r.worktree_path,
|
|
396
|
+
task_id: r.task_id,
|
|
397
|
+
removed: res.removed,
|
|
398
|
+
...(res.reason ? { reason: res.reason } : {}),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return { released, removed_count: removedCount };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* List of active worktrees.
|
|
405
|
+
* @param ticketId when given, narrow to only worktrees assigned to tasks under that ticket
|
|
406
|
+
* (assignments with NULL task_id are excluded).
|
|
407
|
+
*/
|
|
408
|
+
export function listWorktrees(cdb, agentId, ticketId) {
|
|
409
|
+
const clauses = [`status = 'active'`];
|
|
410
|
+
const params = [];
|
|
411
|
+
if (agentId) {
|
|
412
|
+
clauses.push('agent_id = ?');
|
|
413
|
+
params.push(agentId);
|
|
414
|
+
}
|
|
415
|
+
if (ticketId) {
|
|
416
|
+
clauses.push('task_id IN (SELECT id FROM tasks WHERE ticket_id = ?)');
|
|
417
|
+
params.push(ticketId);
|
|
418
|
+
}
|
|
419
|
+
return cdb.db
|
|
420
|
+
.prepare(`SELECT * FROM worktree_assignments WHERE ${clauses.join(' AND ')} ORDER BY created_at ASC`)
|
|
421
|
+
.all(...params);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* §4.5 addendum (#35 option 2, visualization): a read-only pure function that enumerates worktrees still
|
|
425
|
+
* active even though they are tied to done tasks.
|
|
426
|
+
*
|
|
427
|
+
* Background: release_worktree / release_ticket_worktrees have `git worktree remove` (without --force)
|
|
428
|
+
* refused when there are uncommitted tracked diffs (dirty), and prioritize data protection by leaving the
|
|
429
|
+
* ledger active (#33's orphan fix). reconcile's MERGED path auto-releases done-task worktrees (#32), but it
|
|
430
|
+
* is made idempotent by the pr_merged post and does not reprocess that ticket thereafter, so a worktree
|
|
431
|
+
* left active due to dirty is never auto-retried again. The purpose of this function is to let operators
|
|
432
|
+
* observe this asymmetry where it does not orphan yet is not auto-reclaimed. A worktree listed here is
|
|
433
|
+
* handled by stashing the diff (commit/stash/discard) and then calling release_worktree (it cannot be
|
|
434
|
+
* reclaimed by reclaim_orphan_worktrees, which only handles orphans, because it is still active in the ledger).
|
|
435
|
+
*
|
|
436
|
+
* Like releaseWorktreesForTicket, the query JOINs worktree_assignments to tasks via task_id and targets
|
|
437
|
+
* only rows with status='active' and the corresponding task status='done'. Assignments with NULL task_id
|
|
438
|
+
* drop out of the JOIN (= excluded). Whereas list_worktrees returns all active, this function narrows to
|
|
439
|
+
* done tasks and shows only the "unreleased candidates". The server itself releases nothing (observation only).
|
|
440
|
+
*
|
|
441
|
+
* @param args.ticketId when given, narrow with t.ticket_id = ? (all tickets when omitted).
|
|
442
|
+
* @param args.agentId when given, narrow with wa.agent_id = ? (all agents when omitted).
|
|
443
|
+
*/
|
|
444
|
+
export function listStaleWorktrees(cdb, args) {
|
|
445
|
+
const conditions = [
|
|
446
|
+
"wa.status = 'active'",
|
|
447
|
+
"t.status = 'done'",
|
|
448
|
+
];
|
|
449
|
+
const params = [];
|
|
450
|
+
if (args?.ticketId !== undefined) {
|
|
451
|
+
conditions.push('t.ticket_id = ?');
|
|
452
|
+
params.push(args.ticketId);
|
|
453
|
+
}
|
|
454
|
+
if (args?.agentId !== undefined) {
|
|
455
|
+
conditions.push('wa.agent_id = ?');
|
|
456
|
+
params.push(args.agentId);
|
|
457
|
+
}
|
|
458
|
+
return cdb.db
|
|
459
|
+
.prepare(`SELECT wa.*,
|
|
460
|
+
t.status AS task_status,
|
|
461
|
+
t.ticket_id AS ticket_id
|
|
462
|
+
FROM worktree_assignments wa
|
|
463
|
+
JOIN tasks t ON t.id = wa.task_id
|
|
464
|
+
WHERE ${conditions.join(' AND ')}
|
|
465
|
+
ORDER BY wa.created_at ASC`)
|
|
466
|
+
.all(...params);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* §4.5 addendum (together with #33): enumerate/reclaim already-orphaned worktrees.
|
|
470
|
+
*
|
|
471
|
+
* A means to reclaim in operation the orphans born before the #33 fix (the physical worktree and its
|
|
472
|
+
* `git worktree list` registration remain while there is no active ledger row). Read the given repo's
|
|
473
|
+
* `git worktree list --porcelain`, target only the paths under lockage's dug `.lockage-worktrees/`, and
|
|
474
|
+
* match them against the ledger (worktree_assignments).
|
|
475
|
+
*
|
|
476
|
+
* "Physically present but not active in the ledger" = treat as an orphan:
|
|
477
|
+
* - if there is an active row for that worktree_path in the ledger, it is normal (excluded).
|
|
478
|
+
* - if there is no active row but a released row, ledger_status='released' (a pre-#33 orphan).
|
|
479
|
+
* - if there is no row at all, ledger_status='none' (the ledger split / was manually deleted, etc.).
|
|
480
|
+
*
|
|
481
|
+
* dry_run=true (default) only enumerates. When false, attempt `git worktree remove` (without --force) on
|
|
482
|
+
* each orphan. Those with untracked files or tracked uncommitted diffs remaining return removed=false +
|
|
483
|
+
* reason and are not destroyed (do not use --force).
|
|
484
|
+
*
|
|
485
|
+
* @param repoPath the filesystem root of the target repo (the `git -C` target = the main repo).
|
|
486
|
+
* @param dryRun defaults to true (enumeration only). false to attempt reclaim.
|
|
487
|
+
*/
|
|
488
|
+
export function reclaimOrphanWorktrees(cdb, args) {
|
|
489
|
+
const db = cdb.db;
|
|
490
|
+
const dryRun = args.dryRun ?? true;
|
|
491
|
+
// Collect physical paths from the `worktree <path>` lines of `git worktree list --porcelain`.
|
|
492
|
+
// On failure (not a repo, etc.) return empty as "no orphans" (best effort).
|
|
493
|
+
let porcelain;
|
|
494
|
+
try {
|
|
495
|
+
porcelain = git(args.repoPath, ['worktree', 'list', '--porcelain']);
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
return { orphans: [], removed_count: 0 };
|
|
499
|
+
}
|
|
500
|
+
const physicalPaths = [];
|
|
501
|
+
for (const line of porcelain.split('\n')) {
|
|
502
|
+
if (line.startsWith('worktree ')) {
|
|
503
|
+
physicalPaths.push(line.slice('worktree '.length));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// #36: the ledger's worktree_path is the path assignWorktree built with join() (symlinks unresolved),
|
|
507
|
+
// so matching it against the git-derived path (sometimes realpath-resolved) with an exact string compare
|
|
508
|
+
// diverges in some environments. So pull all active/released rows from the ledger, pre-build a set of
|
|
509
|
+
// realpath-normalized worktree_path values, and match by whether the normalized value of the git-derived
|
|
510
|
+
// path is in that set (drop the SQL `= ?` direct comparison). The path count is at most the worktree
|
|
511
|
+
// count, assumed to be not that many. Also keep normalized value → original DB-saved value.
|
|
512
|
+
const activeNorm = new Set();
|
|
513
|
+
const releasedNorm = new Set();
|
|
514
|
+
for (const row of db
|
|
515
|
+
.prepare(`SELECT worktree_path, status FROM worktree_assignments WHERE status IN ('active', 'released')`)
|
|
516
|
+
.all()) {
|
|
517
|
+
const norm = normalizePath(row.worktree_path);
|
|
518
|
+
if (row.status === 'active')
|
|
519
|
+
activeNorm.add(norm);
|
|
520
|
+
else
|
|
521
|
+
releasedNorm.add(norm);
|
|
522
|
+
}
|
|
523
|
+
// #36: exclude the main repo itself with normalized-against-normalized values too (repoPath and git get the same normalization).
|
|
524
|
+
const repoNorm = normalizePath(args.repoPath);
|
|
525
|
+
const orphans = [];
|
|
526
|
+
let removedCount = 0;
|
|
527
|
+
for (const p of physicalPaths) {
|
|
528
|
+
// #36: use the normalized value of the git-derived path for matching (pass the original p to remove as-is).
|
|
529
|
+
const pNorm = normalizePath(p);
|
|
530
|
+
// Do not target the main repo itself or worktrees unmanaged by lockage (only under .lockage-worktrees/).
|
|
531
|
+
if (p === args.repoPath || pNorm === repoNorm)
|
|
532
|
+
continue;
|
|
533
|
+
// The .lockage-worktrees inclusion check targets it if either the original path or the normalized path
|
|
534
|
+
// contains it (do not miss cases where .lockage-worktrees does not appear in the symlink name but only
|
|
535
|
+
// in the real path).
|
|
536
|
+
if (!p.includes('.lockage-worktrees') && !pNorm.includes('.lockage-worktrees'))
|
|
537
|
+
continue;
|
|
538
|
+
// If there is an active row in the ledger, it is normal (not an orphan). Match normalized-against-normalized (#36).
|
|
539
|
+
if (activeNorm.has(pNorm))
|
|
540
|
+
continue;
|
|
541
|
+
// A physical worktree with no active = an orphan. Split ledger_status by the presence of a released row (decided by normalized value).
|
|
542
|
+
const orphan = {
|
|
543
|
+
worktree_path: p,
|
|
544
|
+
ledger_status: releasedNorm.has(pNorm) ? 'released' : 'none',
|
|
545
|
+
};
|
|
546
|
+
if (!dryRun) {
|
|
547
|
+
// Do not use --force. If untracked files or tracked uncommitted diffs remain, git refuses, so leave
|
|
548
|
+
// it with a reason (do not destroy).
|
|
549
|
+
try {
|
|
550
|
+
git(args.repoPath, ['worktree', 'remove', p]);
|
|
551
|
+
orphan.removed = true;
|
|
552
|
+
removedCount += 1;
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
orphan.removed = false;
|
|
556
|
+
orphan.reason = classifyRemoveFailure(p);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
orphans.push(orphan);
|
|
560
|
+
}
|
|
561
|
+
return { orphans, removed_count: removedCount };
|
|
562
|
+
}
|
|
563
|
+
//# sourceMappingURL=worktree.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lockage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Lockage — an MCP server for multiple coding LLMs to collaborate around a local git repository (single host)",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"!dist/**/*.map"
|
|
10
|
+
],
|
|
11
|
+
"bin": {
|
|
12
|
+
"lockage": "dist/index.js",
|
|
13
|
+
"lockage-publish": "dist/launcher/publish-cli.js"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc && cp src/db/schema.sql dist/db/schema.sql && chmod +x dist/index.js dist/launcher/publish-cli.js",
|
|
17
|
+
"prepublishOnly": "rm -rf dist && npm run build",
|
|
18
|
+
"dev": "tsx src/index.ts",
|
|
19
|
+
"pretest": "npm run build",
|
|
20
|
+
"test": "node --test --import tsx test/*.test.ts",
|
|
21
|
+
"test:unit": "node --test --import tsx \"test/!(e2e).test.ts\"",
|
|
22
|
+
"pretest:e2e": "npm run build",
|
|
23
|
+
"test:e2e": "node --test --import tsx test/e2e.test.ts",
|
|
24
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
25
|
+
"sync:plugins": "tsx scripts/sync-plugins.ts"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
29
|
+
"minimatch": "^10.0.0",
|
|
30
|
+
"ulid": "^2.3.0",
|
|
31
|
+
"zod": "^4.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"tsx": "^4.0.0",
|
|
36
|
+
"typescript": "^5.5.0"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22.5"
|
|
40
|
+
},
|
|
41
|
+
"allowScripts": {
|
|
42
|
+
"esbuild@0.28.2": true,
|
|
43
|
+
"fsevents@2.3.3": true
|
|
44
|
+
}
|
|
45
|
+
}
|