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/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
- assignTask,
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 assigned = assignTask(db, task.id, worktree, agent ?? null);
128
- db.close();
134
+ const lease = startTaskLease(db, task.id, worktree, agent ?? null);
129
135
 
130
- if (!assigned) {
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
- const result = { task: { ...task, worktree, status: 'in_progress' } };
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
- const { db } = getContext();
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
- db.close();
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[] } // worktree -> files
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(c.file_path);
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) => ({ id: t.id, title: t.title, worktree: t.worktree, priority: t.priority })),
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
+ });