switchman-dev 0.1.1 → 0.1.2
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/README.md +27 -10
- package/package.json +1 -1
- package/src/cli/index.js +227 -28
- package/src/core/db.js +452 -68
- package/src/core/git.js +8 -1
- package/src/mcp/server.js +170 -22
package/src/core/db.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
-
import { existsSync, mkdirSync
|
|
8
|
-
import { join
|
|
7
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
9
|
|
|
10
10
|
const SWITCHMAN_DIR = '.switchman';
|
|
11
11
|
const DB_FILE = 'switchman.db';
|
|
@@ -13,74 +13,299 @@ const DB_FILE = 'switchman.db';
|
|
|
13
13
|
// How long (ms) a writer will wait for a lock before giving up.
|
|
14
14
|
// 5 seconds is generous for a CLI tool with 3-10 concurrent agents.
|
|
15
15
|
const BUSY_TIMEOUT_MS = 5000;
|
|
16
|
+
const CLAIM_RETRY_DELAY_MS = 100;
|
|
17
|
+
const CLAIM_RETRY_ATTEMPTS = 5;
|
|
18
|
+
export const DEFAULT_STALE_LEASE_MINUTES = 15;
|
|
16
19
|
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
function sleepSync(ms) {
|
|
21
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
function isBusyError(err) {
|
|
25
|
+
const message = String(err?.message || '').toLowerCase();
|
|
26
|
+
return message.includes('database is locked') || message.includes('sqlite_busy');
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const db = new DatabaseSync(getDbPath(repoRoot));
|
|
29
|
+
function makeId(prefix) {
|
|
30
|
+
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
31
|
+
}
|
|
30
32
|
|
|
33
|
+
function configureDb(db) {
|
|
31
34
|
db.exec(`
|
|
35
|
+
PRAGMA foreign_keys=ON;
|
|
32
36
|
PRAGMA journal_mode=WAL;
|
|
33
37
|
PRAGMA synchronous=NORMAL;
|
|
34
38
|
PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};
|
|
39
|
+
`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getTableColumns(db, tableName) {
|
|
43
|
+
return db.prepare(`PRAGMA table_info(${tableName})`).all().map((column) => column.name);
|
|
44
|
+
}
|
|
35
45
|
|
|
46
|
+
function ensureSchema(db) {
|
|
47
|
+
db.exec(`
|
|
36
48
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
37
|
-
id
|
|
38
|
-
title
|
|
39
|
-
description
|
|
40
|
-
status
|
|
41
|
-
worktree
|
|
42
|
-
agent
|
|
43
|
-
priority
|
|
44
|
-
created_at
|
|
45
|
-
updated_at
|
|
49
|
+
id TEXT PRIMARY KEY,
|
|
50
|
+
title TEXT NOT NULL,
|
|
51
|
+
description TEXT,
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
53
|
+
worktree TEXT,
|
|
54
|
+
agent TEXT,
|
|
55
|
+
priority INTEGER DEFAULT 5,
|
|
56
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
57
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
58
|
completed_at TEXT
|
|
47
59
|
);
|
|
48
60
|
|
|
61
|
+
CREATE TABLE IF NOT EXISTS leases (
|
|
62
|
+
id TEXT PRIMARY KEY,
|
|
63
|
+
task_id TEXT NOT NULL,
|
|
64
|
+
worktree TEXT NOT NULL,
|
|
65
|
+
agent TEXT,
|
|
66
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
67
|
+
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
68
|
+
heartbeat_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
69
|
+
finished_at TEXT,
|
|
70
|
+
failure_reason TEXT,
|
|
71
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
72
|
+
);
|
|
73
|
+
|
|
49
74
|
CREATE TABLE IF NOT EXISTS file_claims (
|
|
50
75
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
76
|
task_id TEXT NOT NULL,
|
|
77
|
+
lease_id TEXT,
|
|
52
78
|
file_path TEXT NOT NULL,
|
|
53
79
|
worktree TEXT NOT NULL,
|
|
54
80
|
agent TEXT,
|
|
55
81
|
claimed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
56
82
|
released_at TEXT,
|
|
57
|
-
FOREIGN KEY(task_id) REFERENCES tasks(id)
|
|
83
|
+
FOREIGN KEY(task_id) REFERENCES tasks(id),
|
|
84
|
+
FOREIGN KEY(lease_id) REFERENCES leases(id)
|
|
58
85
|
);
|
|
59
86
|
|
|
60
87
|
CREATE TABLE IF NOT EXISTS worktrees (
|
|
61
|
-
name
|
|
62
|
-
path
|
|
63
|
-
branch
|
|
64
|
-
agent
|
|
65
|
-
status
|
|
88
|
+
name TEXT PRIMARY KEY,
|
|
89
|
+
path TEXT NOT NULL,
|
|
90
|
+
branch TEXT NOT NULL,
|
|
91
|
+
agent TEXT,
|
|
92
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
66
93
|
registered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
67
|
-
last_seen
|
|
94
|
+
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
|
|
68
95
|
);
|
|
69
96
|
|
|
70
97
|
CREATE TABLE IF NOT EXISTS conflict_log (
|
|
71
|
-
id
|
|
72
|
-
detected_at
|
|
73
|
-
worktree_a
|
|
74
|
-
worktree_b
|
|
98
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
99
|
+
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
100
|
+
worktree_a TEXT NOT NULL,
|
|
101
|
+
worktree_b TEXT NOT NULL,
|
|
75
102
|
conflicting_files TEXT NOT NULL,
|
|
76
|
-
resolved
|
|
103
|
+
resolved INTEGER DEFAULT 0
|
|
77
104
|
);
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
const fileClaimColumns = getTableColumns(db, 'file_claims');
|
|
108
|
+
if (fileClaimColumns.length > 0 && !fileClaimColumns.includes('lease_id')) {
|
|
109
|
+
db.exec(`ALTER TABLE file_claims ADD COLUMN lease_id TEXT REFERENCES leases(id)`);
|
|
110
|
+
}
|
|
78
111
|
|
|
112
|
+
db.exec(`
|
|
79
113
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_leases_task ON leases(task_id);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS idx_leases_status ON leases(status);
|
|
116
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_leases_unique_active_task
|
|
117
|
+
ON leases(task_id)
|
|
118
|
+
WHERE status='active';
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_file_claims_task_id ON file_claims(task_id);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_file_claims_lease_id ON file_claims(lease_id);
|
|
80
121
|
CREATE INDEX IF NOT EXISTS idx_file_claims_path ON file_claims(file_path);
|
|
81
122
|
CREATE INDEX IF NOT EXISTS idx_file_claims_active ON file_claims(released_at) WHERE released_at IS NULL;
|
|
123
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_unique_active
|
|
124
|
+
ON file_claims(file_path)
|
|
125
|
+
WHERE released_at IS NULL;
|
|
126
|
+
`);
|
|
127
|
+
|
|
128
|
+
migrateLegacyActiveTasks(db);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function touchWorktreeLeaseState(db, worktree, agent, status) {
|
|
132
|
+
if (!worktree) return;
|
|
133
|
+
db.prepare(`
|
|
134
|
+
UPDATE worktrees
|
|
135
|
+
SET status=?, agent=COALESCE(?, agent), last_seen=datetime('now')
|
|
136
|
+
WHERE name=?
|
|
137
|
+
`).run(status, agent || null, worktree);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getTaskTx(db, taskId) {
|
|
141
|
+
return db.prepare(`SELECT * FROM tasks WHERE id=?`).get(taskId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getLeaseTx(db, leaseId) {
|
|
145
|
+
return db.prepare(`SELECT * FROM leases WHERE id=?`).get(leaseId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getActiveLeaseForTaskTx(db, taskId) {
|
|
149
|
+
return db.prepare(`
|
|
150
|
+
SELECT * FROM leases
|
|
151
|
+
WHERE task_id=? AND status='active'
|
|
152
|
+
ORDER BY started_at DESC
|
|
153
|
+
LIMIT 1
|
|
154
|
+
`).get(taskId);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createLeaseTx(db, { id, taskId, worktree, agent, status = 'active', failureReason = null }) {
|
|
158
|
+
const leaseId = id || makeId('lease');
|
|
159
|
+
db.prepare(`
|
|
160
|
+
INSERT INTO leases (id, task_id, worktree, agent, status, failure_reason)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
162
|
+
`).run(leaseId, taskId, worktree, agent || null, status, failureReason);
|
|
163
|
+
touchWorktreeLeaseState(db, worktree, agent, status === 'active' ? 'busy' : 'idle');
|
|
164
|
+
return getLeaseTx(db, leaseId);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function migrateLegacyActiveTasks(db) {
|
|
168
|
+
const legacyTasks = db.prepare(`
|
|
169
|
+
SELECT *
|
|
170
|
+
FROM tasks
|
|
171
|
+
WHERE status='in_progress'
|
|
172
|
+
AND worktree IS NOT NULL
|
|
173
|
+
AND NOT EXISTS (
|
|
174
|
+
SELECT 1 FROM leases
|
|
175
|
+
WHERE leases.task_id = tasks.id
|
|
176
|
+
AND leases.status='active'
|
|
177
|
+
)
|
|
178
|
+
`).all();
|
|
179
|
+
|
|
180
|
+
if (!legacyTasks.length) return;
|
|
181
|
+
|
|
182
|
+
const backfillClaims = db.prepare(`
|
|
183
|
+
UPDATE file_claims
|
|
184
|
+
SET lease_id=?
|
|
185
|
+
WHERE task_id=? AND released_at IS NULL AND lease_id IS NULL
|
|
82
186
|
`);
|
|
83
187
|
|
|
188
|
+
for (const task of legacyTasks) {
|
|
189
|
+
const lease = createLeaseTx(db, {
|
|
190
|
+
taskId: task.id,
|
|
191
|
+
worktree: task.worktree,
|
|
192
|
+
agent: task.agent,
|
|
193
|
+
});
|
|
194
|
+
backfillClaims.run(lease.id, task.id);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function resolveActiveLeaseTx(db, taskId, worktree, agent) {
|
|
199
|
+
const task = getTaskTx(db, taskId);
|
|
200
|
+
if (!task) {
|
|
201
|
+
throw new Error(`Task ${taskId} does not exist.`);
|
|
202
|
+
}
|
|
203
|
+
if (task.status !== 'in_progress') {
|
|
204
|
+
throw new Error(`Task ${taskId} is not in progress.`);
|
|
205
|
+
}
|
|
206
|
+
if (task.worktree && task.worktree !== worktree) {
|
|
207
|
+
throw new Error(`Task ${taskId} is assigned to worktree ${task.worktree}, not ${worktree}.`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let lease = getActiveLeaseForTaskTx(db, taskId);
|
|
211
|
+
if (lease) {
|
|
212
|
+
if (lease.worktree !== worktree) {
|
|
213
|
+
throw new Error(`Task ${taskId} already has an active lease for worktree ${lease.worktree}, not ${worktree}.`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
db.prepare(`
|
|
217
|
+
UPDATE leases
|
|
218
|
+
SET heartbeat_at=datetime('now'),
|
|
219
|
+
agent=COALESCE(?, agent)
|
|
220
|
+
WHERE id=?
|
|
221
|
+
`).run(agent || null, lease.id);
|
|
222
|
+
touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
|
|
223
|
+
return getLeaseTx(db, lease.id);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
lease = createLeaseTx(db, {
|
|
227
|
+
taskId,
|
|
228
|
+
worktree,
|
|
229
|
+
agent: agent || task.agent,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
db.prepare(`
|
|
233
|
+
UPDATE file_claims
|
|
234
|
+
SET lease_id=?
|
|
235
|
+
WHERE task_id=? AND worktree=? AND released_at IS NULL AND lease_id IS NULL
|
|
236
|
+
`).run(lease.id, taskId, worktree);
|
|
237
|
+
|
|
238
|
+
return lease;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function releaseClaimsForLeaseTx(db, leaseId) {
|
|
242
|
+
db.prepare(`
|
|
243
|
+
UPDATE file_claims
|
|
244
|
+
SET released_at=datetime('now')
|
|
245
|
+
WHERE lease_id=? AND released_at IS NULL
|
|
246
|
+
`).run(leaseId);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function closeActiveLeasesForTaskTx(db, taskId, status, failureReason = null) {
|
|
250
|
+
const activeLeases = db.prepare(`
|
|
251
|
+
SELECT * FROM leases
|
|
252
|
+
WHERE task_id=? AND status='active'
|
|
253
|
+
`).all(taskId);
|
|
254
|
+
|
|
255
|
+
db.prepare(`
|
|
256
|
+
UPDATE leases
|
|
257
|
+
SET status=?,
|
|
258
|
+
finished_at=datetime('now'),
|
|
259
|
+
failure_reason=?
|
|
260
|
+
WHERE task_id=? AND status='active'
|
|
261
|
+
`).run(status, failureReason, taskId);
|
|
262
|
+
|
|
263
|
+
for (const lease of activeLeases) {
|
|
264
|
+
touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return activeLeases;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function withImmediateTransaction(db, fn) {
|
|
271
|
+
for (let attempt = 1; attempt <= CLAIM_RETRY_ATTEMPTS; attempt++) {
|
|
272
|
+
let beganTransaction = false;
|
|
273
|
+
try {
|
|
274
|
+
db.exec('BEGIN IMMEDIATE');
|
|
275
|
+
beganTransaction = true;
|
|
276
|
+
const result = fn();
|
|
277
|
+
db.exec('COMMIT');
|
|
278
|
+
return result;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (beganTransaction) {
|
|
281
|
+
try { db.exec('ROLLBACK'); } catch { /* no-op */ }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (isBusyError(err) && attempt < CLAIM_RETRY_ATTEMPTS) {
|
|
285
|
+
sleepSync(CLAIM_RETRY_DELAY_MS * attempt);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function getSwitchmanDir(repoRoot) {
|
|
295
|
+
return join(repoRoot, SWITCHMAN_DIR);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function getDbPath(repoRoot) {
|
|
299
|
+
return join(repoRoot, SWITCHMAN_DIR, DB_FILE);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function initDb(repoRoot) {
|
|
303
|
+
const dir = getSwitchmanDir(repoRoot);
|
|
304
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
305
|
+
|
|
306
|
+
const db = new DatabaseSync(getDbPath(repoRoot));
|
|
307
|
+
configureDb(db);
|
|
308
|
+
ensureSchema(db);
|
|
84
309
|
return db;
|
|
85
310
|
}
|
|
86
311
|
|
|
@@ -90,14 +315,15 @@ export function openDb(repoRoot) {
|
|
|
90
315
|
throw new Error(`No switchman database found. Run 'switchman init' first.`);
|
|
91
316
|
}
|
|
92
317
|
const db = new DatabaseSync(dbPath);
|
|
93
|
-
db
|
|
318
|
+
configureDb(db);
|
|
319
|
+
ensureSchema(db);
|
|
94
320
|
return db;
|
|
95
321
|
}
|
|
96
322
|
|
|
97
323
|
// ─── Tasks ────────────────────────────────────────────────────────────────────
|
|
98
324
|
|
|
99
325
|
export function createTask(db, { id, title, description, priority = 5 }) {
|
|
100
|
-
const taskId = id ||
|
|
326
|
+
const taskId = id || makeId('task');
|
|
101
327
|
db.prepare(`
|
|
102
328
|
INSERT INTO tasks (id, title, description, priority)
|
|
103
329
|
VALUES (?, ?, ?, ?)
|
|
@@ -105,29 +331,47 @@ export function createTask(db, { id, title, description, priority = 5 }) {
|
|
|
105
331
|
return taskId;
|
|
106
332
|
}
|
|
107
333
|
|
|
334
|
+
export function startTaskLease(db, taskId, worktree, agent) {
|
|
335
|
+
return withImmediateTransaction(db, () => {
|
|
336
|
+
const task = getTaskTx(db, taskId);
|
|
337
|
+
if (!task || task.status !== 'pending') {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
db.prepare(`
|
|
342
|
+
UPDATE tasks
|
|
343
|
+
SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
|
|
344
|
+
WHERE id=? AND status='pending'
|
|
345
|
+
`).run(worktree, agent || null, taskId);
|
|
346
|
+
|
|
347
|
+
return createLeaseTx(db, { taskId, worktree, agent });
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
108
351
|
export function assignTask(db, taskId, worktree, agent) {
|
|
109
|
-
|
|
110
|
-
UPDATE tasks
|
|
111
|
-
SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
|
|
112
|
-
WHERE id=? AND status='pending'
|
|
113
|
-
`).run(worktree, agent || null, taskId);
|
|
114
|
-
return result.changes > 0;
|
|
352
|
+
return Boolean(startTaskLease(db, taskId, worktree, agent));
|
|
115
353
|
}
|
|
116
354
|
|
|
117
355
|
export function completeTask(db, taskId) {
|
|
118
|
-
db
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
356
|
+
withImmediateTransaction(db, () => {
|
|
357
|
+
db.prepare(`
|
|
358
|
+
UPDATE tasks
|
|
359
|
+
SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
|
|
360
|
+
WHERE id=?
|
|
361
|
+
`).run(taskId);
|
|
362
|
+
closeActiveLeasesForTaskTx(db, taskId, 'completed');
|
|
363
|
+
});
|
|
123
364
|
}
|
|
124
365
|
|
|
125
366
|
export function failTask(db, taskId, reason) {
|
|
126
|
-
db
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
367
|
+
withImmediateTransaction(db, () => {
|
|
368
|
+
db.prepare(`
|
|
369
|
+
UPDATE tasks
|
|
370
|
+
SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
|
|
371
|
+
WHERE id=?
|
|
372
|
+
`).run(reason || 'unknown', taskId);
|
|
373
|
+
closeActiveLeasesForTaskTx(db, taskId, 'failed', reason || 'unknown');
|
|
374
|
+
});
|
|
131
375
|
}
|
|
132
376
|
|
|
133
377
|
export function listTasks(db, statusFilter) {
|
|
@@ -148,27 +392,160 @@ export function getNextPendingTask(db) {
|
|
|
148
392
|
`).get();
|
|
149
393
|
}
|
|
150
394
|
|
|
395
|
+
export function listLeases(db, statusFilter) {
|
|
396
|
+
if (statusFilter) {
|
|
397
|
+
return db.prepare(`
|
|
398
|
+
SELECT l.*, t.title AS task_title
|
|
399
|
+
FROM leases l
|
|
400
|
+
JOIN tasks t ON l.task_id = t.id
|
|
401
|
+
WHERE l.status=?
|
|
402
|
+
ORDER BY l.started_at DESC
|
|
403
|
+
`).all(statusFilter);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return db.prepare(`
|
|
407
|
+
SELECT l.*, t.title AS task_title
|
|
408
|
+
FROM leases l
|
|
409
|
+
JOIN tasks t ON l.task_id = t.id
|
|
410
|
+
ORDER BY l.started_at DESC
|
|
411
|
+
`).all();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function getLease(db, leaseId) {
|
|
415
|
+
return db.prepare(`
|
|
416
|
+
SELECT l.*, t.title AS task_title
|
|
417
|
+
FROM leases l
|
|
418
|
+
JOIN tasks t ON l.task_id = t.id
|
|
419
|
+
WHERE l.id=?
|
|
420
|
+
`).get(leaseId);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function getActiveLeaseForTask(db, taskId) {
|
|
424
|
+
const lease = getActiveLeaseForTaskTx(db, taskId);
|
|
425
|
+
return lease ? getLease(db, lease.id) : null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function heartbeatLease(db, leaseId, agent) {
|
|
429
|
+
const result = db.prepare(`
|
|
430
|
+
UPDATE leases
|
|
431
|
+
SET heartbeat_at=datetime('now'),
|
|
432
|
+
agent=COALESCE(?, agent)
|
|
433
|
+
WHERE id=? AND status='active'
|
|
434
|
+
`).run(agent || null, leaseId);
|
|
435
|
+
|
|
436
|
+
if (result.changes === 0) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const lease = getLease(db, leaseId);
|
|
441
|
+
touchWorktreeLeaseState(db, lease.worktree, agent || lease.agent, 'busy');
|
|
442
|
+
return lease;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function getStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES) {
|
|
446
|
+
return db.prepare(`
|
|
447
|
+
SELECT l.*, t.title AS task_title
|
|
448
|
+
FROM leases l
|
|
449
|
+
JOIN tasks t ON l.task_id = t.id
|
|
450
|
+
WHERE l.status='active'
|
|
451
|
+
AND l.heartbeat_at < datetime('now', ?)
|
|
452
|
+
ORDER BY l.heartbeat_at ASC
|
|
453
|
+
`).all(`-${staleAfterMinutes} minutes`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUTES) {
|
|
457
|
+
return withImmediateTransaction(db, () => {
|
|
458
|
+
const staleLeases = getStaleLeases(db, staleAfterMinutes);
|
|
459
|
+
if (!staleLeases.length) {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const expireLease = db.prepare(`
|
|
464
|
+
UPDATE leases
|
|
465
|
+
SET status='expired',
|
|
466
|
+
finished_at=datetime('now'),
|
|
467
|
+
failure_reason=COALESCE(failure_reason, 'stale lease reaped')
|
|
468
|
+
WHERE id=? AND status='active'
|
|
469
|
+
`);
|
|
470
|
+
|
|
471
|
+
const resetTask = db.prepare(`
|
|
472
|
+
UPDATE tasks
|
|
473
|
+
SET status='pending',
|
|
474
|
+
worktree=NULL,
|
|
475
|
+
agent=NULL,
|
|
476
|
+
updated_at=datetime('now')
|
|
477
|
+
WHERE id=? AND status='in_progress'
|
|
478
|
+
AND NOT EXISTS (
|
|
479
|
+
SELECT 1 FROM leases
|
|
480
|
+
WHERE task_id=?
|
|
481
|
+
AND status='active'
|
|
482
|
+
)
|
|
483
|
+
`);
|
|
484
|
+
|
|
485
|
+
for (const lease of staleLeases) {
|
|
486
|
+
expireLease.run(lease.id);
|
|
487
|
+
releaseClaimsForLeaseTx(db, lease.id);
|
|
488
|
+
resetTask.run(lease.task_id, lease.task_id);
|
|
489
|
+
touchWorktreeLeaseState(db, lease.worktree, lease.agent, 'idle');
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return staleLeases.map((lease) => ({
|
|
493
|
+
...lease,
|
|
494
|
+
status: 'expired',
|
|
495
|
+
failure_reason: lease.failure_reason || 'stale lease reaped',
|
|
496
|
+
}));
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
151
500
|
// ─── File Claims ──────────────────────────────────────────────────────────────
|
|
152
501
|
|
|
153
502
|
export function claimFiles(db, taskId, worktree, filePaths, agent) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
503
|
+
return withImmediateTransaction(db, () => {
|
|
504
|
+
const lease = resolveActiveLeaseTx(db, taskId, worktree, agent);
|
|
505
|
+
const findActiveClaim = db.prepare(`
|
|
506
|
+
SELECT *
|
|
507
|
+
FROM file_claims
|
|
508
|
+
WHERE file_path=? AND released_at IS NULL
|
|
509
|
+
LIMIT 1
|
|
510
|
+
`);
|
|
511
|
+
const insert = db.prepare(`
|
|
512
|
+
INSERT INTO file_claims (task_id, lease_id, file_path, worktree, agent)
|
|
513
|
+
VALUES (?, ?, ?, ?, ?)
|
|
514
|
+
`);
|
|
515
|
+
|
|
164
516
|
for (const fp of filePaths) {
|
|
165
|
-
|
|
517
|
+
const existing = findActiveClaim.get(fp);
|
|
518
|
+
if (existing) {
|
|
519
|
+
const sameLease = existing.lease_id === lease.id;
|
|
520
|
+
const sameLegacyOwner = existing.lease_id == null && existing.task_id === taskId && existing.worktree === worktree;
|
|
521
|
+
|
|
522
|
+
if (sameLease || sameLegacyOwner) {
|
|
523
|
+
if (sameLegacyOwner) {
|
|
524
|
+
db.prepare(`
|
|
525
|
+
UPDATE file_claims
|
|
526
|
+
SET lease_id=?, agent=COALESCE(?, agent)
|
|
527
|
+
WHERE id=?
|
|
528
|
+
`).run(lease.id, agent || null, existing.id);
|
|
529
|
+
}
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
throw new Error('One or more files are already actively claimed by another task.');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
insert.run(taskId, lease.id, fp, worktree, agent || null);
|
|
166
537
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
538
|
+
|
|
539
|
+
db.prepare(`
|
|
540
|
+
UPDATE leases
|
|
541
|
+
SET heartbeat_at=datetime('now'),
|
|
542
|
+
agent=COALESCE(?, agent)
|
|
543
|
+
WHERE id=?
|
|
544
|
+
`).run(agent || null, lease.id);
|
|
545
|
+
|
|
546
|
+
touchWorktreeLeaseState(db, worktree, agent || lease.agent, 'busy');
|
|
547
|
+
return getLeaseTx(db, lease.id);
|
|
548
|
+
});
|
|
172
549
|
}
|
|
173
550
|
|
|
174
551
|
export function releaseFileClaims(db, taskId) {
|
|
@@ -178,11 +555,17 @@ export function releaseFileClaims(db, taskId) {
|
|
|
178
555
|
`).run(taskId);
|
|
179
556
|
}
|
|
180
557
|
|
|
558
|
+
export function releaseLeaseFileClaims(db, leaseId) {
|
|
559
|
+
releaseClaimsForLeaseTx(db, leaseId);
|
|
560
|
+
}
|
|
561
|
+
|
|
181
562
|
export function getActiveFileClaims(db) {
|
|
182
563
|
return db.prepare(`
|
|
183
|
-
SELECT fc.*, t.title as task_title, t.status as task_status
|
|
564
|
+
SELECT fc.*, t.title as task_title, t.status as task_status,
|
|
565
|
+
l.id as lease_id, l.status as lease_status, l.heartbeat_at as lease_heartbeat_at
|
|
184
566
|
FROM file_claims fc
|
|
185
567
|
JOIN tasks t ON fc.task_id = t.id
|
|
568
|
+
LEFT JOIN leases l ON fc.lease_id = l.id
|
|
186
569
|
WHERE fc.released_at IS NULL
|
|
187
570
|
ORDER BY fc.file_path
|
|
188
571
|
`).all();
|
|
@@ -191,9 +574,10 @@ export function getActiveFileClaims(db) {
|
|
|
191
574
|
export function checkFileConflicts(db, filePaths, excludeWorktree) {
|
|
192
575
|
const conflicts = [];
|
|
193
576
|
const stmt = db.prepare(`
|
|
194
|
-
SELECT fc.*, t.title as task_title
|
|
577
|
+
SELECT fc.*, t.title as task_title, l.id as lease_id, l.status as lease_status
|
|
195
578
|
FROM file_claims fc
|
|
196
579
|
JOIN tasks t ON fc.task_id = t.id
|
|
580
|
+
LEFT JOIN leases l ON fc.lease_id = l.id
|
|
197
581
|
WHERE fc.file_path=?
|
|
198
582
|
AND fc.released_at IS NULL
|
|
199
583
|
AND fc.worktree != ?
|
package/src/core/git.js
CHANGED
|
@@ -114,9 +114,16 @@ export function getWorktreeChangedFiles(worktreePath, repoRoot) {
|
|
|
114
114
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
115
115
|
}).trim();
|
|
116
116
|
|
|
117
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
118
|
+
cwd: worktreePath,
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
121
|
+
}).trim();
|
|
122
|
+
|
|
117
123
|
const allFiles = [
|
|
118
124
|
...staged.split('\n'),
|
|
119
125
|
...unstaged.split('\n'),
|
|
126
|
+
...untracked.split('\n'),
|
|
120
127
|
].filter(Boolean);
|
|
121
128
|
|
|
122
129
|
return [...new Set(allFiles)];
|
|
@@ -262,4 +269,4 @@ export function getWorktreeStats(worktreePath) {
|
|
|
262
269
|
} catch {
|
|
263
270
|
return { modified: 0, added: 0, deleted: 0, total: 0, raw: '' };
|
|
264
271
|
}
|
|
265
|
-
}
|
|
272
|
+
}
|