switchman-dev 0.1.0 → 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.
Files changed (38) hide show
  1. package/README.md +27 -10
  2. package/package.json +5 -5
  3. package/src/cli/index.js +227 -28
  4. package/src/core/db.js +452 -68
  5. package/src/core/git.js +8 -1
  6. package/src/mcp/server.js +170 -22
  7. package/CLAUDE.md +0 -98
  8. package/examples/taskapi/.switchman/switchman.db +0 -0
  9. package/examples/taskapi/package-lock.json +0 -4736
  10. package/examples/taskapi/tests/api.test.js +0 -112
  11. package/examples/worktrees/agent-rate-limiting/package-lock.json +0 -4736
  12. package/examples/worktrees/agent-rate-limiting/package.json +0 -18
  13. package/examples/worktrees/agent-rate-limiting/src/db.js +0 -179
  14. package/examples/worktrees/agent-rate-limiting/src/middleware/auth.js +0 -96
  15. package/examples/worktrees/agent-rate-limiting/src/middleware/validate.js +0 -133
  16. package/examples/worktrees/agent-rate-limiting/src/routes/tasks.js +0 -65
  17. package/examples/worktrees/agent-rate-limiting/src/routes/users.js +0 -38
  18. package/examples/worktrees/agent-rate-limiting/src/server.js +0 -7
  19. package/examples/worktrees/agent-rate-limiting/tests/api.test.js +0 -112
  20. package/examples/worktrees/agent-tests/package-lock.json +0 -4736
  21. package/examples/worktrees/agent-tests/package.json +0 -18
  22. package/examples/worktrees/agent-tests/src/db.js +0 -179
  23. package/examples/worktrees/agent-tests/src/middleware/auth.js +0 -96
  24. package/examples/worktrees/agent-tests/src/middleware/validate.js +0 -133
  25. package/examples/worktrees/agent-tests/src/routes/tasks.js +0 -65
  26. package/examples/worktrees/agent-tests/src/routes/users.js +0 -38
  27. package/examples/worktrees/agent-tests/src/server.js +0 -7
  28. package/examples/worktrees/agent-tests/tests/api.test.js +0 -112
  29. package/examples/worktrees/agent-validation/package-lock.json +0 -4736
  30. package/examples/worktrees/agent-validation/package.json +0 -18
  31. package/examples/worktrees/agent-validation/src/db.js +0 -179
  32. package/examples/worktrees/agent-validation/src/middleware/auth.js +0 -96
  33. package/examples/worktrees/agent-validation/src/middleware/validate.js +0 -133
  34. package/examples/worktrees/agent-validation/src/routes/tasks.js +0 -65
  35. package/examples/worktrees/agent-validation/src/routes/users.js +0 -38
  36. package/examples/worktrees/agent-validation/src/server.js +0 -7
  37. package/examples/worktrees/agent-validation/tests/api.test.js +0 -112
  38. package/tests/test.js +0 -259
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, readFileSync, writeFileSync } from 'fs';
8
- import { join, resolve } from 'path';
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
- export function getSwitchmanDir(repoRoot) {
18
- return join(repoRoot, SWITCHMAN_DIR);
20
+ function sleepSync(ms) {
21
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
19
22
  }
20
23
 
21
- export function getDbPath(repoRoot) {
22
- return join(repoRoot, SWITCHMAN_DIR, DB_FILE);
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
- export function initDb(repoRoot) {
26
- const dir = getSwitchmanDir(repoRoot);
27
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
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 TEXT PRIMARY KEY,
38
- title TEXT NOT NULL,
39
- description TEXT,
40
- status TEXT NOT NULL DEFAULT 'pending',
41
- worktree TEXT,
42
- agent TEXT,
43
- priority INTEGER DEFAULT 5,
44
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
45
- updated_at TEXT NOT NULL DEFAULT (datetime('now')),
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 TEXT PRIMARY KEY,
62
- path TEXT NOT NULL,
63
- branch TEXT NOT NULL,
64
- agent TEXT,
65
- status TEXT NOT NULL DEFAULT 'idle',
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 TEXT NOT NULL DEFAULT (datetime('now'))
94
+ last_seen TEXT NOT NULL DEFAULT (datetime('now'))
68
95
  );
69
96
 
70
97
  CREATE TABLE IF NOT EXISTS conflict_log (
71
- id INTEGER PRIMARY KEY AUTOINCREMENT,
72
- detected_at TEXT NOT NULL DEFAULT (datetime('now')),
73
- worktree_a TEXT NOT NULL,
74
- worktree_b TEXT NOT NULL,
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 INTEGER DEFAULT 0
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.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=${BUSY_TIMEOUT_MS};`);
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 || `task-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
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
- const result = db.prepare(`
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.prepare(`
119
- UPDATE tasks
120
- SET status='done', completed_at=datetime('now'), updated_at=datetime('now')
121
- WHERE id=?
122
- `).run(taskId);
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.prepare(`
127
- UPDATE tasks
128
- SET status='failed', description=COALESCE(description,'') || '\nFAILED: ' || ?, updated_at=datetime('now')
129
- WHERE id=?
130
- `).run(reason || 'unknown', taskId);
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
- const insert = db.prepare(`
155
- INSERT INTO file_claims (task_id, file_path, worktree, agent)
156
- VALUES (?, ?, ?, ?)
157
- `);
158
- // node:sqlite's DatabaseSync doesn't have .transaction() like better-sqlite3.
159
- // We use explicit BEGIN/COMMIT/ROLLBACK. The key correctness fix vs. the old
160
- // code: we only ROLLBACK if we're actually inside a transaction (i.e. after
161
- // BEGIN succeeded), and we re-throw so callers can handle failures.
162
- db.exec('BEGIN');
163
- try {
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
- insert.run(taskId, fp, worktree, agent || null);
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
- db.exec('COMMIT');
168
- } catch (err) {
169
- try { db.exec('ROLLBACK'); } catch { /* already rolled back */ }
170
- throw err;
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
+ }