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/mcp/server.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* switchman_task_claim — claim files for a task (conflict-safe)
|
|
12
12
|
* switchman_task_done — mark a task complete + release file claims
|
|
13
13
|
* switchman_task_fail — mark a task failed + release file claims
|
|
14
|
+
* switchman_lease_heartbeat — refresh a lease heartbeat
|
|
14
15
|
* switchman_scan — scan all worktrees for conflicts right now
|
|
15
16
|
* switchman_status — full system overview (tasks, claims, worktrees)
|
|
16
17
|
*/
|
|
@@ -23,12 +24,15 @@ import { findRepoRoot } from '../core/git.js';
|
|
|
23
24
|
import {
|
|
24
25
|
openDb,
|
|
25
26
|
createTask,
|
|
26
|
-
|
|
27
|
+
startTaskLease,
|
|
27
28
|
completeTask,
|
|
28
29
|
failTask,
|
|
29
30
|
listTasks,
|
|
30
|
-
getTask,
|
|
31
31
|
getNextPendingTask,
|
|
32
|
+
listLeases,
|
|
33
|
+
getActiveLeaseForTask,
|
|
34
|
+
heartbeatLease,
|
|
35
|
+
getStaleLeases,
|
|
32
36
|
claimFiles,
|
|
33
37
|
releaseFileClaims,
|
|
34
38
|
getActiveFileClaims,
|
|
@@ -95,7 +99,10 @@ Returns JSON:
|
|
|
95
99
|
"description": string | null,
|
|
96
100
|
"priority": number, // 1-10, higher = more urgent
|
|
97
101
|
"worktree": string,
|
|
98
|
-
"status": "in_progress"
|
|
102
|
+
"status": "in_progress",
|
|
103
|
+
"lease_id": string, // Active lease/session ID for the task
|
|
104
|
+
"lease_status": "active",
|
|
105
|
+
"heartbeat_at": string
|
|
99
106
|
} | null // null when queue is empty
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -124,17 +131,27 @@ Examples:
|
|
|
124
131
|
return toolOk(JSON.stringify({ task: null }), { task: null });
|
|
125
132
|
}
|
|
126
133
|
|
|
127
|
-
const
|
|
128
|
-
db.close();
|
|
134
|
+
const lease = startTaskLease(db, task.id, worktree, agent ?? null);
|
|
129
135
|
|
|
130
|
-
if (!
|
|
136
|
+
if (!lease) {
|
|
137
|
+
db.close();
|
|
131
138
|
// Race condition: another agent grabbed it first — try again
|
|
132
139
|
return toolOk(
|
|
133
140
|
JSON.stringify({ task: null, message: 'Task was claimed by another agent. Call switchman_task_next again.' }),
|
|
134
141
|
);
|
|
135
142
|
}
|
|
136
143
|
|
|
137
|
-
|
|
144
|
+
db.close();
|
|
145
|
+
const result = {
|
|
146
|
+
task: {
|
|
147
|
+
...task,
|
|
148
|
+
worktree,
|
|
149
|
+
status: 'in_progress',
|
|
150
|
+
lease_id: lease.id,
|
|
151
|
+
lease_status: lease.status,
|
|
152
|
+
heartbeat_at: lease.heartbeat_at,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
138
155
|
return toolOk(JSON.stringify(result, null, 2), result);
|
|
139
156
|
} catch (err) {
|
|
140
157
|
return toolError(`${err.message}. Make sure switchman is initialised in this repo (run 'switchman init').`);
|
|
@@ -210,16 +227,20 @@ Args:
|
|
|
210
227
|
- worktree (string): Your git worktree name
|
|
211
228
|
- files (array of strings): File paths you plan to edit, relative to repo root (e.g. ["src/auth/login.js", "tests/auth.test.js"])
|
|
212
229
|
- agent (string, optional): Agent identifier for logging
|
|
230
|
+
- lease_id (string, optional): Active lease ID returned by switchman_task_next
|
|
213
231
|
- force (boolean, optional): If true, claim even if conflicts exist (default: false)
|
|
214
232
|
|
|
215
233
|
Returns JSON:
|
|
216
234
|
{
|
|
235
|
+
"lease_id": string,
|
|
217
236
|
"claimed": string[], // Files successfully claimed
|
|
218
237
|
"conflicts": [ // Files already claimed by other worktrees
|
|
219
238
|
{
|
|
220
239
|
"file": string,
|
|
240
|
+
"claimed_by_task_id": string,
|
|
221
241
|
"claimed_by_worktree": string,
|
|
222
|
-
"claimed_by_task": string
|
|
242
|
+
"claimed_by_task": string,
|
|
243
|
+
"claimed_by_lease_id": string | null
|
|
223
244
|
}
|
|
224
245
|
],
|
|
225
246
|
"safe_to_proceed": boolean // true if no conflicts (or force=true)
|
|
@@ -234,6 +255,7 @@ Examples:
|
|
|
234
255
|
worktree: z.string().min(1).describe('Your git worktree name'),
|
|
235
256
|
files: z.array(z.string().min(1)).min(1).max(500).describe('File paths to claim, relative to repo root'),
|
|
236
257
|
agent: z.string().optional().describe('Agent identifier for logging'),
|
|
258
|
+
lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
|
|
237
259
|
force: z.boolean().default(false).describe('Claim even if conflicts exist (use with caution)'),
|
|
238
260
|
}),
|
|
239
261
|
annotations: {
|
|
@@ -243,9 +265,10 @@ Examples:
|
|
|
243
265
|
openWorldHint: false,
|
|
244
266
|
},
|
|
245
267
|
},
|
|
246
|
-
async ({ task_id, worktree, files, agent, force }) => {
|
|
268
|
+
async ({ task_id, worktree, files, agent, lease_id, force }) => {
|
|
269
|
+
let db;
|
|
247
270
|
try {
|
|
248
|
-
|
|
271
|
+
({ db } = getContext());
|
|
249
272
|
|
|
250
273
|
// Check for conflicts first
|
|
251
274
|
const conflicts = checkFileConflicts(db, files, worktree);
|
|
@@ -256,8 +279,10 @@ Examples:
|
|
|
256
279
|
claimed: [],
|
|
257
280
|
conflicts: conflicts.map((c) => ({
|
|
258
281
|
file: c.file,
|
|
282
|
+
claimed_by_task_id: c.claimedBy.task_id,
|
|
259
283
|
claimed_by_worktree: c.claimedBy.worktree,
|
|
260
284
|
claimed_by_task: c.claimedBy.task_title,
|
|
285
|
+
claimed_by_lease_id: c.claimedBy.lease_id ?? null,
|
|
261
286
|
})),
|
|
262
287
|
safe_to_proceed: false,
|
|
263
288
|
};
|
|
@@ -268,15 +293,20 @@ Examples:
|
|
|
268
293
|
);
|
|
269
294
|
}
|
|
270
295
|
|
|
271
|
-
claimFiles(db, task_id, worktree, files, agent ?? null);
|
|
272
|
-
|
|
296
|
+
const lease = claimFiles(db, task_id, worktree, files, agent ?? null);
|
|
297
|
+
if (lease_id && lease.id !== lease_id) {
|
|
298
|
+
return toolError(`Task ${task_id} is active under lease ${lease.id}, not ${lease_id}.`);
|
|
299
|
+
}
|
|
273
300
|
|
|
274
301
|
const result = {
|
|
302
|
+
lease_id: lease.id,
|
|
275
303
|
claimed: files,
|
|
276
304
|
conflicts: conflicts.map((c) => ({
|
|
277
305
|
file: c.file,
|
|
306
|
+
claimed_by_task_id: c.claimedBy.task_id,
|
|
278
307
|
claimed_by_worktree: c.claimedBy.worktree,
|
|
279
308
|
claimed_by_task: c.claimedBy.task_title,
|
|
309
|
+
claimed_by_lease_id: c.claimedBy.lease_id ?? null,
|
|
280
310
|
})),
|
|
281
311
|
safe_to_proceed: true,
|
|
282
312
|
};
|
|
@@ -286,6 +316,8 @@ Examples:
|
|
|
286
316
|
);
|
|
287
317
|
} catch (err) {
|
|
288
318
|
return toolError(err.message);
|
|
319
|
+
} finally {
|
|
320
|
+
db?.close();
|
|
289
321
|
}
|
|
290
322
|
},
|
|
291
323
|
);
|
|
@@ -302,15 +334,18 @@ Call this when you have finished your implementation and committed your changes.
|
|
|
302
334
|
|
|
303
335
|
Args:
|
|
304
336
|
- task_id (string): The task ID to complete
|
|
337
|
+
- lease_id (string, optional): Active lease ID returned by switchman_task_next
|
|
305
338
|
|
|
306
339
|
Returns JSON:
|
|
307
340
|
{
|
|
308
341
|
"task_id": string,
|
|
342
|
+
"lease_id": string | null,
|
|
309
343
|
"status": "done",
|
|
310
344
|
"files_released": true
|
|
311
345
|
}`,
|
|
312
346
|
inputSchema: z.object({
|
|
313
347
|
task_id: z.string().min(1).describe('The task ID to mark complete'),
|
|
348
|
+
lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
|
|
314
349
|
}),
|
|
315
350
|
annotations: {
|
|
316
351
|
readOnlyHint: false,
|
|
@@ -319,14 +354,19 @@ Returns JSON:
|
|
|
319
354
|
openWorldHint: false,
|
|
320
355
|
},
|
|
321
356
|
},
|
|
322
|
-
async ({ task_id }) => {
|
|
357
|
+
async ({ task_id, lease_id }) => {
|
|
323
358
|
try {
|
|
324
359
|
const { db } = getContext();
|
|
360
|
+
const activeLease = getActiveLeaseForTask(db, task_id);
|
|
361
|
+
if (lease_id && activeLease && activeLease.id !== lease_id) {
|
|
362
|
+
db.close();
|
|
363
|
+
return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
|
|
364
|
+
}
|
|
325
365
|
completeTask(db, task_id);
|
|
326
366
|
releaseFileClaims(db, task_id);
|
|
327
367
|
db.close();
|
|
328
368
|
|
|
329
|
-
const result = { task_id, status: 'done', files_released: true };
|
|
369
|
+
const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'done', files_released: true };
|
|
330
370
|
return toolOk(JSON.stringify(result, null, 2), result);
|
|
331
371
|
} catch (err) {
|
|
332
372
|
return toolError(err.message);
|
|
@@ -346,17 +386,20 @@ Call this if you cannot complete the task — the task will be visible in the qu
|
|
|
346
386
|
|
|
347
387
|
Args:
|
|
348
388
|
- task_id (string): The task ID to mark as failed
|
|
389
|
+
- lease_id (string, optional): Active lease ID returned by switchman_task_next
|
|
349
390
|
- reason (string): Brief explanation of why the task failed
|
|
350
391
|
|
|
351
392
|
Returns JSON:
|
|
352
393
|
{
|
|
353
394
|
"task_id": string,
|
|
395
|
+
"lease_id": string | null,
|
|
354
396
|
"status": "failed",
|
|
355
397
|
"reason": string,
|
|
356
398
|
"files_released": true
|
|
357
399
|
}`,
|
|
358
400
|
inputSchema: z.object({
|
|
359
401
|
task_id: z.string().min(1).describe('The task ID to mark as failed'),
|
|
402
|
+
lease_id: z.string().optional().describe('Optional lease ID returned by switchman_task_next'),
|
|
360
403
|
reason: z.string().min(1).max(500).describe('Brief explanation of why the task failed'),
|
|
361
404
|
}),
|
|
362
405
|
annotations: {
|
|
@@ -366,14 +409,74 @@ Returns JSON:
|
|
|
366
409
|
openWorldHint: false,
|
|
367
410
|
},
|
|
368
411
|
},
|
|
369
|
-
async ({ task_id, reason }) => {
|
|
412
|
+
async ({ task_id, lease_id, reason }) => {
|
|
370
413
|
try {
|
|
371
414
|
const { db } = getContext();
|
|
415
|
+
const activeLease = getActiveLeaseForTask(db, task_id);
|
|
416
|
+
if (lease_id && activeLease && activeLease.id !== lease_id) {
|
|
417
|
+
db.close();
|
|
418
|
+
return toolError(`Task ${task_id} is active under lease ${activeLease.id}, not ${lease_id}.`);
|
|
419
|
+
}
|
|
372
420
|
failTask(db, task_id, reason);
|
|
373
421
|
releaseFileClaims(db, task_id);
|
|
374
422
|
db.close();
|
|
375
423
|
|
|
376
|
-
const result = { task_id, status: 'failed', reason, files_released: true };
|
|
424
|
+
const result = { task_id, lease_id: activeLease?.id ?? lease_id ?? null, status: 'failed', reason, files_released: true };
|
|
425
|
+
return toolOk(JSON.stringify(result, null, 2), result);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
return toolError(err.message);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// ── switchman_lease_heartbeat ─────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
server.registerTool(
|
|
435
|
+
'switchman_lease_heartbeat',
|
|
436
|
+
{
|
|
437
|
+
title: 'Refresh Lease Heartbeat',
|
|
438
|
+
description: `Refreshes the heartbeat timestamp for an active lease.
|
|
439
|
+
|
|
440
|
+
Call this periodically while an agent is still working on a task so stale-session reaping does not recycle the task prematurely.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
- lease_id (string): Active lease ID returned by switchman_task_next
|
|
444
|
+
- agent (string, optional): Agent identifier to attach to the refreshed lease
|
|
445
|
+
|
|
446
|
+
Returns JSON:
|
|
447
|
+
{
|
|
448
|
+
"lease_id": string,
|
|
449
|
+
"task_id": string,
|
|
450
|
+
"worktree": string,
|
|
451
|
+
"heartbeat_at": string
|
|
452
|
+
}`,
|
|
453
|
+
inputSchema: z.object({
|
|
454
|
+
lease_id: z.string().min(1).describe('Active lease ID returned by switchman_task_next'),
|
|
455
|
+
agent: z.string().optional().describe('Agent identifier for logging'),
|
|
456
|
+
}),
|
|
457
|
+
annotations: {
|
|
458
|
+
readOnlyHint: false,
|
|
459
|
+
destructiveHint: false,
|
|
460
|
+
idempotentHint: true,
|
|
461
|
+
openWorldHint: false,
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
async ({ lease_id, agent }) => {
|
|
465
|
+
try {
|
|
466
|
+
const { db } = getContext();
|
|
467
|
+
const lease = heartbeatLease(db, lease_id, agent ?? null);
|
|
468
|
+
db.close();
|
|
469
|
+
|
|
470
|
+
if (!lease) {
|
|
471
|
+
return toolError(`Lease ${lease_id} was not found or is no longer active.`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const result = {
|
|
475
|
+
lease_id: lease.id,
|
|
476
|
+
task_id: lease.task_id,
|
|
477
|
+
worktree: lease.worktree,
|
|
478
|
+
heartbeat_at: lease.heartbeat_at,
|
|
479
|
+
};
|
|
377
480
|
return toolOk(JSON.stringify(result, null, 2), result);
|
|
378
481
|
} catch (err) {
|
|
379
482
|
return toolError(err.message);
|
|
@@ -479,13 +582,17 @@ Returns JSON:
|
|
|
479
582
|
"in_progress": number,
|
|
480
583
|
"done": number,
|
|
481
584
|
"failed": number,
|
|
482
|
-
"active": [{ "id": string, "title": string, "worktree": string, "priority": number }]
|
|
585
|
+
"active": [{ "id": string, "title": string, "worktree": string, "priority": number, "lease_id": string | null }]
|
|
483
586
|
},
|
|
484
587
|
"file_claims": {
|
|
485
588
|
"total_active": number,
|
|
486
|
-
"by_worktree": { [worktree: string]: string[] }
|
|
589
|
+
"by_worktree": { [worktree: string]: { "file_path": string, "task_id": string, "lease_id": string | null }[] }
|
|
590
|
+
},
|
|
591
|
+
"leases": {
|
|
592
|
+
"active": [{ "id": string, "task_id": string, "worktree": string, "agent": string | null, "heartbeat_at": string }],
|
|
593
|
+
"stale": [{ "id": string, "task_id": string, "worktree": string, "heartbeat_at": string }]
|
|
487
594
|
},
|
|
488
|
-
"worktrees": [{ "name": string, "branch": string, "agent": string | null, "status": string }],
|
|
595
|
+
"worktrees": [{ "name": string, "branch": string, "agent": string | null, "status": string, "active_lease_id": string | null }],
|
|
489
596
|
"repo_root": string
|
|
490
597
|
}`,
|
|
491
598
|
inputSchema: z.object({}),
|
|
@@ -503,14 +610,27 @@ Returns JSON:
|
|
|
503
610
|
const tasks = listTasks(db);
|
|
504
611
|
const claims = getActiveFileClaims(db);
|
|
505
612
|
const worktrees = listWorktrees(db);
|
|
613
|
+
const leases = listLeases(db);
|
|
614
|
+
const staleLeases = getStaleLeases(db);
|
|
506
615
|
db.close();
|
|
507
616
|
|
|
508
617
|
const byWorktree = {};
|
|
509
618
|
for (const c of claims) {
|
|
510
619
|
if (!byWorktree[c.worktree]) byWorktree[c.worktree] = [];
|
|
511
|
-
byWorktree[c.worktree].push(
|
|
620
|
+
byWorktree[c.worktree].push({
|
|
621
|
+
file_path: c.file_path,
|
|
622
|
+
task_id: c.task_id,
|
|
623
|
+
lease_id: c.lease_id ?? null,
|
|
624
|
+
});
|
|
512
625
|
}
|
|
513
626
|
|
|
627
|
+
const activeLeaseByTask = new Map(
|
|
628
|
+
leases.filter((lease) => lease.status === 'active').map((lease) => [lease.task_id, lease]),
|
|
629
|
+
);
|
|
630
|
+
const activeLeaseByWorktree = new Map(
|
|
631
|
+
leases.filter((lease) => lease.status === 'active').map((lease) => [lease.worktree, lease]),
|
|
632
|
+
);
|
|
633
|
+
|
|
514
634
|
const result = {
|
|
515
635
|
tasks: {
|
|
516
636
|
pending: tasks.filter((t) => t.status === 'pending').length,
|
|
@@ -519,17 +639,45 @@ Returns JSON:
|
|
|
519
639
|
failed: tasks.filter((t) => t.status === 'failed').length,
|
|
520
640
|
active: tasks
|
|
521
641
|
.filter((t) => t.status === 'in_progress')
|
|
522
|
-
.map((t) => ({
|
|
642
|
+
.map((t) => ({
|
|
643
|
+
id: t.id,
|
|
644
|
+
title: t.title,
|
|
645
|
+
worktree: t.worktree,
|
|
646
|
+
priority: t.priority,
|
|
647
|
+
lease_id: activeLeaseByTask.get(t.id)?.id ?? null,
|
|
648
|
+
})),
|
|
523
649
|
},
|
|
524
650
|
file_claims: {
|
|
525
651
|
total_active: claims.length,
|
|
526
652
|
by_worktree: byWorktree,
|
|
653
|
+
claims: claims.map((claim) => ({
|
|
654
|
+
file_path: claim.file_path,
|
|
655
|
+
worktree: claim.worktree,
|
|
656
|
+
task_id: claim.task_id,
|
|
657
|
+
lease_id: claim.lease_id ?? null,
|
|
658
|
+
})),
|
|
659
|
+
},
|
|
660
|
+
leases: {
|
|
661
|
+
active: leases.filter((lease) => lease.status === 'active').map((lease) => ({
|
|
662
|
+
id: lease.id,
|
|
663
|
+
task_id: lease.task_id,
|
|
664
|
+
worktree: lease.worktree,
|
|
665
|
+
agent: lease.agent ?? null,
|
|
666
|
+
heartbeat_at: lease.heartbeat_at,
|
|
667
|
+
})),
|
|
668
|
+
stale: staleLeases.map((lease) => ({
|
|
669
|
+
id: lease.id,
|
|
670
|
+
task_id: lease.task_id,
|
|
671
|
+
worktree: lease.worktree,
|
|
672
|
+
heartbeat_at: lease.heartbeat_at,
|
|
673
|
+
})),
|
|
527
674
|
},
|
|
528
675
|
worktrees: worktrees.map((wt) => ({
|
|
529
676
|
name: wt.name,
|
|
530
677
|
branch: wt.branch,
|
|
531
678
|
agent: wt.agent ?? null,
|
|
532
679
|
status: wt.status,
|
|
680
|
+
active_lease_id: activeLeaseByWorktree.get(wt.name)?.id ?? null,
|
|
533
681
|
})),
|
|
534
682
|
repo_root: repoRoot,
|
|
535
683
|
};
|
|
@@ -552,4 +700,4 @@ async function main() {
|
|
|
552
700
|
main().catch((err) => {
|
|
553
701
|
process.stderr.write(`switchman MCP server fatal error: ${err.message}\n`);
|
|
554
702
|
process.exit(1);
|
|
555
|
-
});
|
|
703
|
+
});
|