claude-code-session-manager 0.83.0 → 0.84.0

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 (92) hide show
  1. package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-CCVIpoSz.js} +1 -1
  2. package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-BltREYde.js} +1 -1
  3. package/dist/assets/{History-D3Qw2BWn.js → History-BZxFkOp6.js} +2 -2
  4. package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-Bznlfaaa.js} +1 -1
  5. package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-DUG5YHA_.js} +1 -1
  6. package/dist/assets/{Library-DAMyrUtY.js → Library-Bi2Fn3w9.js} +1 -1
  7. package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-4VBvXKrz.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-D2ft_v5j.js} +1 -1
  9. package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-FDekKkyE.js} +1 -1
  10. package/dist/assets/{Memory-CgYJ_QqD.js → Memory-Dkl80uj_.js} +6 -6
  11. package/dist/assets/{Panel-dTNWC59Q.js → Panel-LurG5VfD.js} +1 -1
  12. package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-D2wHBCFA.js} +1 -1
  13. package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-CTw_wwbI.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-CW6HNUv6.js} +1 -1
  15. package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-alDHG6cP.js} +1 -1
  16. package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-DxiPcaiW.js} +7 -7
  17. package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-DzFXUKLZ.js} +1 -1
  18. package/dist/assets/Settings-B6H3v2am.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-B8EZalpN.js} +1 -1
  20. package/dist/assets/{Skills-DHb4INtF.js → Skills-DwuHuA08.js} +2 -2
  21. package/dist/assets/SystemPrompt-CMMqpGYn.js +1 -0
  22. package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-C2N5C1n9.js} +1 -1
  23. package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-btlID-dQ.js} +1 -1
  24. package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-BGOn3DZj.js} +1 -1
  25. package/dist/assets/{index-CzAxC432.js → index-QLRf0epp.js} +316 -314
  26. package/dist/assets/{index-CYhdtisq.css → index-mnjNDpb1.css} +1 -1
  27. package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-DA3N2Up3.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +4 -1
  30. package/scripts/hooks/guard-destructive-git.cjs +514 -0
  31. package/scripts/hooks/guard-inline-implementation.cjs +219 -0
  32. package/scripts/hooks/guard-prd-writes.cjs +200 -0
  33. package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
  34. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  35. package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
  36. package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
  37. package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
  38. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +143 -0
  39. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
  40. package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
  41. package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
  42. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
  43. package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
  44. package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
  45. package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
  46. package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
  47. package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
  48. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
  49. package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
  50. package/src/main/__tests__/telemetryClient.test.cjs +66 -0
  51. package/src/main/__tests__/telemetryContract.test.cjs +883 -0
  52. package/src/main/crashDiagnostics.cjs +29 -1
  53. package/src/main/health.cjs +197 -3
  54. package/src/main/index.cjs +65 -6
  55. package/src/main/ipcSchemas.cjs +19 -2
  56. package/src/main/lib/__tests__/crashTelemetry.test.cjs +97 -0
  57. package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
  58. package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
  59. package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
  60. package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
  61. package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
  62. package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
  63. package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
  64. package/src/main/lib/__tests__/telemetryBacklog.test.cjs +620 -0
  65. package/src/main/lib/__tests__/telemetryBoot.test.cjs +125 -0
  66. package/src/main/lib/__tests__/telemetryConsent.test.cjs +130 -0
  67. package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
  68. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +89 -0
  69. package/src/main/lib/crashTelemetry.cjs +37 -0
  70. package/src/main/lib/delegationReadiness.cjs +119 -1
  71. package/src/main/lib/epicMint.cjs +2 -0
  72. package/src/main/lib/gitWorktree.cjs +427 -17
  73. package/src/main/lib/jobWorktree.cjs +2 -1
  74. package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
  75. package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
  76. package/src/main/lib/opsErrorLog.cjs +78 -25
  77. package/src/main/lib/queueStore.cjs +233 -20
  78. package/src/main/lib/reaperHelpers.cjs +23 -1
  79. package/src/main/lib/scheduleJobSchema.cjs +7 -0
  80. package/src/main/lib/scheduleJobTransitions.cjs +12 -0
  81. package/src/main/lib/telemetryBacklog.cjs +601 -0
  82. package/src/main/lib/telemetryBoot.cjs +71 -0
  83. package/src/main/lib/telemetryClient.cjs +31 -0
  84. package/src/main/lib/telemetryConsent.cjs +34 -0
  85. package/src/main/lib/telemetryCounters.cjs +45 -0
  86. package/src/main/promptSessionTranscript.cjs +0 -0
  87. package/src/main/pty.cjs +2 -0
  88. package/src/main/scheduler.cjs +481 -33
  89. package/src/preload/api.d.ts +84 -4
  90. package/src/preload/index.cjs +9 -0
  91. package/dist/assets/Settings-DXVgKLUx.js +0 -3
  92. package/dist/assets/SystemPrompt-BjqFOHSk.js +0 -1
@@ -15,7 +15,7 @@ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
15
15
  const fs = require('node:fs');
16
16
  const os = require('node:os');
17
17
  const path = require('node:path');
18
- const { execFileSync } = require('node:child_process');
18
+ const { execFileSync, spawn } = require('node:child_process');
19
19
  const gitWorktree = require('../gitWorktree.cjs');
20
20
 
21
21
  let tmpRoot;
@@ -53,6 +53,7 @@ beforeEach(() => {
53
53
  delete process.env.SM_EPIC_WORKTREE_MAX;
54
54
  gitWorktree._resetActiveWorktreeCountForTests('job', 0);
55
55
  gitWorktree._resetActiveWorktreeCountForTests('epic', 0);
56
+ gitWorktree._resetObservedWorktreeCountCacheForTests();
56
57
  });
57
58
 
58
59
  afterEach(async () => {
@@ -315,6 +316,189 @@ test('[job] falls back once the concurrency cap is reached, and recovers after c
315
316
  await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: third.dir, branch: third.branch });
316
317
  });
317
318
 
319
+ test('[job] a leaked cap count (checkout removed from disk without cleanupWorktree) self-heals on the next createWorktree', async () => {
320
+ process.env.SM_JOB_WORKTREE_MAX = '1';
321
+ const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-1' });
322
+ expect(first.ok).toBe(true);
323
+
324
+ // Simulate the crash/reap path: the checkout is torn off disk directly
325
+ // (not via cleanupJobWorktree), exactly like a job that got SIGTERM'd or
326
+ // reaped before its own teardown ran — activeWorktreeCount never gets
327
+ // decremented, so the in-memory counter is now permanently stuck at the
328
+ // cap even though nothing real is holding it.
329
+ await execFileSync('git', ['worktree', 'remove', '--force', first.dir], { cwd: repoCwd, encoding: 'utf8' });
330
+ fs.rmSync(first.dir, { recursive: true, force: true });
331
+ expect(gitWorktree._getActiveWorktreeCountForTests('job')).toBe(1);
332
+
333
+ // Against unpatched main this next call stays wedged forever: the counter
334
+ // never reflects that nothing is actually checked out anymore.
335
+ const second = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'leak-2' });
336
+ expect(second.ok).toBe(true);
337
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: second.dir, branch: second.branch });
338
+ });
339
+
340
+ test('[job] reclaimTerminalJobOrphans removes a completed row\'s clean-of-real-work orphan checkout', async () => {
341
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-clean' });
342
+ expect(worktree.ok).toBe(true);
343
+ // Zero commits ahead of main and nothing dirty at all in the checkout —
344
+ // the simplest "safe to reclaim" shape.
345
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
346
+ cwd: repoCwd,
347
+ terminalSlugs: new Set(['orphan-clean']),
348
+ });
349
+ expect(reclaimed).toEqual(['orphan-clean']);
350
+ expect(fs.existsSync(worktree.dir)).toBe(false);
351
+ });
352
+
353
+ test('[job] reclaimTerminalJobOrphans reclaims a checkout carrying only ops-folder dirty noise', async () => {
354
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-ops-noise' });
355
+ expect(worktree.ok).toBe(true);
356
+ fs.mkdirSync(path.join(worktree.dir, 'session-manager-operations'), { recursive: true });
357
+ fs.writeFileSync(path.join(worktree.dir, 'session-manager-operations', 'queue.json'), '{}\n', 'utf8');
358
+
359
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
360
+ cwd: repoCwd,
361
+ terminalSlugs: new Set(['orphan-ops-noise']),
362
+ });
363
+ expect(reclaimed).toEqual(['orphan-ops-noise']);
364
+ expect(fs.existsSync(worktree.dir)).toBe(false);
365
+ });
366
+
367
+ test('[job] reclaimTerminalJobOrphans leaves a checkout with real (non-ops) dirty content alone', async () => {
368
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-real-work' });
369
+ expect(worktree.ok).toBe(true);
370
+ fs.writeFileSync(path.join(worktree.dir, 'real-output.txt'), 'not ops noise\n', 'utf8');
371
+
372
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
373
+ cwd: repoCwd,
374
+ terminalSlugs: new Set(['orphan-real-work']),
375
+ });
376
+ expect(reclaimed).toEqual([]);
377
+ expect(fs.existsSync(worktree.dir)).toBe(true);
378
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
379
+ });
380
+
381
+ test('[job] reclaimTerminalJobOrphans leaves a checkout with unmerged commits alone', async () => {
382
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'orphan-unmerged' });
383
+ expect(worktree.ok).toBe(true);
384
+ fs.writeFileSync(path.join(worktree.dir, 'new-file.txt'), 'real committed work\n', 'utf8');
385
+ git(['add', '-A'], worktree.dir);
386
+ git(['commit', '-q', '-m', 'unmerged job commit'], worktree.dir);
387
+
388
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
389
+ cwd: repoCwd,
390
+ terminalSlugs: new Set(['orphan-unmerged']),
391
+ });
392
+ expect(reclaimed).toEqual([]);
393
+ expect(fs.existsSync(worktree.dir)).toBe(true);
394
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
395
+ });
396
+
397
+ test('[job] reclaimTerminalJobOrphans ignores a worktree whose slug is not in terminalSlugs', async () => {
398
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'not-terminal' });
399
+ expect(worktree.ok).toBe(true);
400
+
401
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
402
+ cwd: repoCwd,
403
+ terminalSlugs: new Set(['some-other-slug']),
404
+ });
405
+ expect(reclaimed).toEqual([]);
406
+ expect(fs.existsSync(worktree.dir)).toBe(true);
407
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
408
+ });
409
+
410
+ // ──────────────────────────────────────────── PRD 1163: liveness gate in front of reclaimTerminalJobOrphans
411
+
412
+ test('[job] reclaimTerminalJobOrphans does NOT remove a checkout when an injected isLive predicate reports it live', async () => {
413
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-but-live' });
414
+ expect(worktree.ok).toBe(true);
415
+ // Clean-of-real-work shape (zero commits ahead, nothing dirty) — would be
416
+ // reclaimed by the existing two proofs alone. The liveness gate must be
417
+ // checked BEFORE those proofs and override them.
418
+ const isLive = vi.fn(async () => true);
419
+
420
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
421
+ cwd: repoCwd,
422
+ terminalSlugs: new Set(['terminal-but-live']),
423
+ isLive,
424
+ });
425
+
426
+ expect(reclaimed).toEqual([]);
427
+ expect(fs.existsSync(worktree.dir)).toBe(true);
428
+ expect(isLive).toHaveBeenCalledWith('terminal-but-live', expect.objectContaining({ worktree: worktree.dir }));
429
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
430
+ });
431
+
432
+ test('[job] reclaimTerminalJobOrphans still removes a terminal, dead, merged, ops-only-dirty checkout when isLive reports it not live', async () => {
433
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'terminal-and-dead' });
434
+ expect(worktree.ok).toBe(true);
435
+ const isLive = vi.fn(async () => false);
436
+
437
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
438
+ cwd: repoCwd,
439
+ terminalSlugs: new Set(['terminal-and-dead']),
440
+ isLive,
441
+ });
442
+
443
+ expect(reclaimed).toEqual(['terminal-and-dead']);
444
+ expect(fs.existsSync(worktree.dir)).toBe(false);
445
+ expect(isLive).toHaveBeenCalled();
446
+ });
447
+
448
+ test('[job] reclaimTerminalJobOrphans treats an isLive that throws as fail-safe (assume live, do not delete)', async () => {
449
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'isLive-throws' });
450
+ expect(worktree.ok).toBe(true);
451
+ const isLive = vi.fn(async () => { throw new Error('boom'); });
452
+
453
+ const reclaimed = await gitWorktree.reclaimTerminalJobOrphans({
454
+ cwd: repoCwd,
455
+ terminalSlugs: new Set(['isLive-throws']),
456
+ isLive,
457
+ });
458
+
459
+ expect(reclaimed).toEqual([]);
460
+ expect(fs.existsSync(worktree.dir)).toBe(true);
461
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
462
+ });
463
+
464
+ test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive skips on a live cwd holder BEFORE checking pid, and reports the reason via onLive', async () => {
465
+ const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
466
+ const claudePidAlive = vi.fn(() => false); // pid check must never even matter here
467
+ const hasLiveHolder = vi.fn(() => true);
468
+ const onLive = vi.fn();
469
+ const isLive = buildTerminalOrphanIsLive({
470
+ terminalJobs: [{ slug: 'held-slug', runtime: { pid: 4242 } }],
471
+ claudePidAlive,
472
+ hasLiveHolder,
473
+ cwdHolders: new Set(['/tmp/fake']),
474
+ onLive,
475
+ });
476
+
477
+ const result = await isLive('held-slug', { worktree: '/tmp/fake/checkout' });
478
+
479
+ expect(result).toBe(true);
480
+ expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/fake/checkout', expect.any(Set));
481
+ expect(onLive).toHaveBeenCalledWith('held-slug', expect.stringContaining('live cwd holder'));
482
+ });
483
+
484
+ test('[job] jobWorktreeTerminalOrphanLive.buildTerminalOrphanIsLive falls back to the recorded pid when there is no live holder', async () => {
485
+ const { buildTerminalOrphanIsLive } = require('../jobWorktreeTerminalOrphanLive.cjs');
486
+ const claudePidAlive = vi.fn((pid) => pid === 4242);
487
+ const hasLiveHolder = vi.fn(() => false);
488
+ const onLive = vi.fn();
489
+ const isLive = buildTerminalOrphanIsLive({
490
+ terminalJobs: [{ slug: 'pid-slug', runtime: { pid: 4242 } }],
491
+ claudePidAlive,
492
+ hasLiveHolder,
493
+ onLive,
494
+ });
495
+
496
+ expect(await isLive('pid-slug', { worktree: '/tmp/whatever' })).toBe(true);
497
+ expect(onLive).toHaveBeenCalledWith('pid-slug', expect.stringContaining('pid=4242'));
498
+
499
+ expect(await isLive('unknown-slug', { worktree: '/tmp/whatever' })).toBe(false);
500
+ });
501
+
318
502
  test('[job] getMaxConcurrentWorktrees floors the job cap at the sessionSlots pool size (PRD 1112)', () => {
319
503
  const originalSlots = process.env.SM_SESSION_SLOTS;
320
504
  try {
@@ -390,6 +574,104 @@ test('[job] reconcileWorktreesOnBoot never touches a worktree outside WORKTREE_R
390
574
  git(['worktree', 'remove', '--force', humanWorktreeDir], repoCwd);
391
575
  });
392
576
 
577
+ // ──────────────────────────────────────────── PRD 1162: job-kind isLive gate
578
+ // (a job survives an app restart via detached:true, so a worktree found at
579
+ // boot is not by itself proof its run already died — see scheduler.cjs's
580
+ // boot sweep + jobWorktreeBootLive.cjs, exercised here at the
581
+ // reconcileWorktreesOnBoot layer with a fully injected isLive predicate).
582
+
583
+ test('[job] reconcileWorktreesOnBoot removes a job worktree with no live holder and no running row', async () => {
584
+ const slug = 'test-slug-dead';
585
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
586
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
587
+
588
+ const isLive = vi.fn(() => false);
589
+ await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
590
+
591
+ expect(fs.existsSync(worktree.dir)).toBe(false);
592
+ expect(isLive).toHaveBeenCalledWith(slug, expect.objectContaining({ worktree: worktree.dir }));
593
+ });
594
+
595
+ test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is running with a live pid', async () => {
596
+ const slug = 'test-slug-running-live';
597
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
598
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
599
+
600
+ const isLive = (key) => key === slug;
601
+ const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
602
+
603
+ expect(fs.existsSync(worktree.dir)).toBe(true);
604
+ expect(result.checkoutsRemoved).toBe(0);
605
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
606
+ });
607
+
608
+ test('[job] reconcileWorktreesOnBoot SKIPS a worktree whose row is absent/terminal but a live cwd holder is reported', async () => {
609
+ const slug = 'test-slug-terminal-holder';
610
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
611
+ gitWorktree._resetActiveWorktreeCountForTests('job', 0);
612
+
613
+ // Row is absent from the caller's bookkeeping entirely (as if the queue row
614
+ // already went terminal) — only the injected cwd-holder signal says live.
615
+ const isLive = (key, entry) => entry.worktree === worktree.dir;
616
+ const result = await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job', isLive });
617
+
618
+ expect(fs.existsSync(worktree.dir)).toBe(true);
619
+ expect(result.checkoutsRemoved).toBe(0);
620
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
621
+ });
622
+
623
+ // ──────────────────────────────────────────── PRD 1162: hasLiveHolder / listCwdHolders
624
+
625
+ test('hasLiveHolder returns true for the current process own cwd', () => {
626
+ if (process.platform !== 'linux') return; // darwin has no /proc — covered by the pid-based gate instead
627
+ expect(gitWorktree.hasLiveHolder(process.cwd())).toBe(true);
628
+ });
629
+
630
+ test('hasLiveHolder returns false for a freshly-created empty temp dir nothing holds as cwd', () => {
631
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-empty-'));
632
+ try {
633
+ expect(gitWorktree.hasLiveHolder(emptyDir)).toBe(false);
634
+ } finally {
635
+ fs.rmSync(emptyDir, { recursive: true, force: true });
636
+ }
637
+ });
638
+
639
+ test('hasLiveHolder never throws for a non-existent path', () => {
640
+ expect(() => gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).not.toThrow();
641
+ expect(gitWorktree.hasLiveHolder('/definitely/does/not/exist/anywhere')).toBe(false);
642
+ });
643
+
644
+ test('hasLiveHolder still matches a checkout dir whose /proc readlink shows the "(deleted)" suffix (unlinked while a process still held it as cwd)', async () => {
645
+ if (process.platform !== 'linux') return;
646
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-hasLiveHolder-deleted-'));
647
+ const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
648
+ try {
649
+ // Give the child a moment to actually chdir before the dir is unlinked.
650
+ await new Promise((resolve) => setTimeout(resolve, 150));
651
+ fs.rmSync(dir, { recursive: true, force: true });
652
+ expect(gitWorktree.hasLiveHolder(dir)).toBe(true);
653
+ } finally {
654
+ child.kill();
655
+ }
656
+ });
657
+
658
+ test('hasLiveHolder accepts a precomputed holders Set from listCwdHolders() and matches a path nested under a holder', () => {
659
+ if (process.platform !== 'linux') return; // darwin: hasLiveHolder always false, per its own contract
660
+ const holders = new Set(['/tmp/some-worktree-checkout']);
661
+ expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout/nested/deep', holders)).toBe(false);
662
+ expect(gitWorktree.hasLiveHolder('/tmp/some-worktree-checkout', holders)).toBe(true);
663
+ });
664
+
665
+ test('listCwdHolders returns a Set and includes the current process own cwd on linux', () => {
666
+ const holders = gitWorktree.listCwdHolders();
667
+ expect(holders).toBeInstanceOf(Set);
668
+ if (process.platform === 'linux') {
669
+ expect(holders.has(path.resolve(process.cwd()))).toBe(true);
670
+ } else {
671
+ expect(holders.size).toBe(0);
672
+ }
673
+ });
674
+
393
675
  // ──────────────────────────────────────────── epic kind (new)
394
676
 
395
677
  test('[epic] createEpicWorktree names the branch sm-epic/<id>, distinct from the job root', async () => {
@@ -657,6 +939,29 @@ test('[job] sweepStaleWorktreeCheckouts treats staleAgeMs: 0 as "reclaim immedia
657
939
  expect(fs.existsSync(worktree.dir)).toBe(false);
658
940
  });
659
941
 
942
+ test('[job] sweepStaleWorktreeCheckouts does NOT remove an old-mtime checkout with a live holder, regardless of age', async () => {
943
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-but-live' });
944
+ expect(worktree.ok).toBe(true);
945
+ const holders = new Set([path.resolve(worktree.dir)]);
946
+
947
+ const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
948
+
949
+ expect(result.checkoutsRemoved).toBe(0);
950
+ expect(fs.existsSync(worktree.dir)).toBe(true);
951
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
952
+ });
953
+
954
+ test('[job] sweepStaleWorktreeCheckouts removes an old-mtime checkout when there is no live holder', async () => {
955
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-and-dead' });
956
+ expect(worktree.ok).toBe(true);
957
+ const holders = new Set(); // nothing holds it
958
+
959
+ const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
960
+
961
+ expect(result.checkoutsRemoved).toBe(1);
962
+ expect(fs.existsSync(worktree.dir)).toBe(false);
963
+ });
964
+
660
965
  test('[job] sweepStaleWorktreeCheckouts never deletes anything outside its own kind root, even via a symlink shaped like a traversal', async () => {
661
966
  const outsideDir = path.join(tmpRoot, 'outside-root');
662
967
  fs.mkdirSync(outsideDir, { recursive: true });
@@ -802,3 +1107,110 @@ test('[job] integrateBranch aborts cleanly when the single retry also fails on a
802
1107
 
803
1108
  await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
804
1109
  });
1110
+
1111
+ // ──────────────────────────────────────────── default-branch integration guard (PRD: starry-night-ships wrong-base merges)
1112
+
1113
+ test('[guard] integrateBranch proceeds normally when HEAD is on the repo default branch', async () => {
1114
+ const slug = 'test-slug-guard-default-ok';
1115
+ const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1116
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1117
+ expect(worktree.ok).toBe(true);
1118
+
1119
+ fs.writeFileSync(path.join(worktree.dir, 'guard-ok.txt'), 'job output\n', 'utf8');
1120
+ git(['add', '-A'], worktree.dir);
1121
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1122
+
1123
+ expect(await gitWorktree.getCurrentBranch(repoCwd)).toBe(defaultBranch);
1124
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1125
+ expect(outcome.ok).toBe(true);
1126
+ expect(outcome.integrated).toBe(true);
1127
+
1128
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1129
+ });
1130
+
1131
+ test('[guard] integrateBranch refuses when HEAD is on a stray sm-job/* branch, naming both branches in the reason, and mutates nothing', async () => {
1132
+ const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1133
+ const slugB = 'test-slug-guard-stray-b';
1134
+ const strayBranch = 'sm-job/leftover-from-a-prior-run';
1135
+ const worktreeB = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: slugB });
1136
+ fs.writeFileSync(path.join(worktreeB.dir, 'guard-stray.txt'), 'branch b output\n', 'utf8');
1137
+ git(['add', '-A'], worktreeB.dir);
1138
+ git(['commit', '-q', '-m', 'job commit b'], worktreeB.dir);
1139
+
1140
+ // Simulate the live incident: the SHARED tree's own HEAD got checked out
1141
+ // onto a leftover job branch instead of staying on the default branch.
1142
+ git(['checkout', '-b', strayBranch], repoCwd);
1143
+ const headBefore = git(['rev-parse', 'HEAD'], repoCwd).trim();
1144
+ const statusBefore = git(['status', '--porcelain'], repoCwd);
1145
+
1146
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktreeB.branch, slug: slugB });
1147
+ expect(outcome.ok).toBe(false);
1148
+ expect(outcome.reason).toContain(defaultBranch);
1149
+ expect(outcome.reason).toContain(strayBranch);
1150
+
1151
+ // Refusing must not touch git state: no checkout/reset/switch performed.
1152
+ expect(git(['rev-parse', 'HEAD'], repoCwd).trim()).toBe(headBefore);
1153
+ expect(git(['status', '--porcelain'], repoCwd)).toBe(statusBefore);
1154
+
1155
+ git(['checkout', defaultBranch], repoCwd);
1156
+ git(['branch', '-D', strayBranch], repoCwd);
1157
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktreeB.dir, branch: worktreeB.branch, keepBranch: true });
1158
+ try { git(['branch', '-D', worktreeB.branch], repoCwd); } catch { /* best-effort */ }
1159
+ });
1160
+
1161
+ test('[guard] integrateBranch refuses on a detached HEAD, reporting "detached HEAD" rather than treating it as a branch', async () => {
1162
+ const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1163
+ const slug = 'test-slug-guard-detached';
1164
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1165
+ fs.writeFileSync(path.join(worktree.dir, 'guard-detached.txt'), 'output\n', 'utf8');
1166
+ git(['add', '-A'], worktree.dir);
1167
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1168
+
1169
+ const headSha = git(['rev-parse', 'HEAD'], repoCwd).trim();
1170
+ git(['checkout', '--detach', headSha], repoCwd);
1171
+ expect(await gitWorktree.getCurrentBranch(repoCwd)).toBeNull();
1172
+
1173
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1174
+ expect(outcome.ok).toBe(false);
1175
+ expect(outcome.reason).toContain('detached HEAD');
1176
+ expect(outcome.reason).toContain(defaultBranch);
1177
+
1178
+ git(['checkout', defaultBranch], repoCwd);
1179
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch, keepBranch: true });
1180
+ try { git(['branch', '-D', worktree.branch], repoCwd); } catch { /* best-effort */ }
1181
+ });
1182
+
1183
+ test('[guard] resolveDefaultBranch resolves a sensible default (the sole local branch) for a repo with no remote, and integration still proceeds', async () => {
1184
+ const remoteOut = execFileSync('git', ['remote'], { cwd: repoCwd, encoding: 'utf8' }).trim();
1185
+ expect(remoteOut).toBe('');
1186
+ const defaultBranch = git(['symbolic-ref', '--short', 'HEAD'], repoCwd).trim();
1187
+ expect(await gitWorktree.resolveDefaultBranch(repoCwd)).toBe(defaultBranch);
1188
+
1189
+ const slug = 'test-slug-guard-no-remote';
1190
+ const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug });
1191
+ fs.writeFileSync(path.join(worktree.dir, 'guard-no-remote.txt'), 'output\n', 'utf8');
1192
+ git(['add', '-A'], worktree.dir);
1193
+ git(['commit', '-q', '-m', 'job commit'], worktree.dir);
1194
+
1195
+ const outcome = await gitWorktree.integrateJobBranch({ cwd: repoCwd, branch: worktree.branch, slug });
1196
+ expect(outcome.ok).toBe(true);
1197
+ expect(outcome.integrated).toBe(true);
1198
+
1199
+ await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1200
+ });
1201
+
1202
+ test('[guard][epic] integrateEpicBranch inherits the same guard and still merges cleanly when HEAD is on the default branch', async () => {
1203
+ const epicId = 'test-epic-guard-ok';
1204
+ const worktree = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId });
1205
+ expect(worktree.ok).toBe(true);
1206
+
1207
+ fs.writeFileSync(path.join(worktree.dir, 'epic-guard.txt'), 'epic output\n', 'utf8');
1208
+ git(['add', '-A'], worktree.dir);
1209
+ git(['commit', '-q', '-m', 'epic commit'], worktree.dir);
1210
+
1211
+ const outcome = await gitWorktree.integrateEpicBranch({ cwd: repoCwd, branch: worktree.branch, epicId });
1212
+ expect(outcome.ok).toBe(true);
1213
+ expect(outcome.integrated).toBe(true);
1214
+
1215
+ await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: worktree.dir, branch: worktree.branch });
1216
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * jobWorktreeBootLive.test.cjs — PRD 1162: pure unit tests for the `isLive`
3
+ * predicate the scheduler's boot worktree sweep hands to
4
+ * jobWorktree.reconcileWorktreesOnBoot. Liveness is fully injected/stubbed —
5
+ * no /proc, no git, no electron.
6
+ *
7
+ * Run: timeout 60 npx vitest run src/main/lib/__tests__/jobWorktreeBootLive.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, vi } from 'vitest';
13
+ const { buildJobWorktreeIsLive } = require('../jobWorktreeBootLive.cjs');
14
+
15
+ test('isLive returns true when the row is running with a live pid', () => {
16
+ const claudePidAlive = vi.fn(() => true);
17
+ const hasLiveHolder = vi.fn(() => false);
18
+ const isLive = buildJobWorktreeIsLive({
19
+ bootJobs: [{ slug: 'job-a', status: 'running', runtime: { pid: 4242 } }],
20
+ claudePidAlive,
21
+ hasLiveHolder,
22
+ });
23
+ expect(isLive('job-a', { worktree: '/tmp/whatever' })).toBe(true);
24
+ expect(claudePidAlive).toHaveBeenCalledWith(4242);
25
+ expect(hasLiveHolder).not.toHaveBeenCalled();
26
+ });
27
+
28
+ test('isLive returns false when the row is running but the pid is dead and no cwd holder exists', () => {
29
+ const isLive = buildJobWorktreeIsLive({
30
+ bootJobs: [{ slug: 'job-a', status: 'running', runtime: { pid: 4242 } }],
31
+ claudePidAlive: () => false,
32
+ hasLiveHolder: () => false,
33
+ });
34
+ expect(isLive('job-a', { worktree: '/tmp/whatever' })).toBe(false);
35
+ });
36
+
37
+ test('isLive returns true via hasLiveHolder when the row is absent/terminal but a process still holds the checkout as cwd', () => {
38
+ const claudePidAlive = vi.fn(() => true);
39
+ const hasLiveHolder = vi.fn(() => true);
40
+ const isLive = buildJobWorktreeIsLive({
41
+ bootJobs: [{ slug: 'job-b', status: 'completed', runtime: { pid: 999 } }],
42
+ claudePidAlive,
43
+ hasLiveHolder,
44
+ });
45
+ expect(isLive('job-b', { worktree: '/tmp/checkout-b' })).toBe(true);
46
+ // Terminal row never even consults claudePidAlive — only a 'running' row does.
47
+ expect(claudePidAlive).not.toHaveBeenCalled();
48
+ expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/checkout-b', undefined);
49
+ });
50
+
51
+ test('isLive returns false when there is no matching row and no cwd holder', () => {
52
+ const isLive = buildJobWorktreeIsLive({
53
+ bootJobs: [],
54
+ claudePidAlive: () => true,
55
+ hasLiveHolder: () => false,
56
+ });
57
+ expect(isLive('job-c', { worktree: '/tmp/checkout-c' })).toBe(false);
58
+ });
59
+
60
+ test('a precomputed cwdHolders set is forwarded through to hasLiveHolder unchanged', () => {
61
+ const holders = new Set(['/tmp/precomputed']);
62
+ const hasLiveHolder = vi.fn(() => false);
63
+ const isLive = buildJobWorktreeIsLive({
64
+ bootJobs: [],
65
+ claudePidAlive: () => false,
66
+ hasLiveHolder,
67
+ cwdHolders: holders,
68
+ });
69
+ isLive('job-d', { worktree: '/tmp/checkout-d' });
70
+ expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/checkout-d', holders);
71
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * queueStoreAtomicWrite.test.cjs — regression test for the tmp-path collision
3
+ * that tore `scheduler-machine.json` four times (`.corrupt-*` Sep 7/10/11,
4
+ * plus a `.bak-*`). The old `writeJsonAtomic`/`writeJsonAtomicSync` used
5
+ * `${file}.tmp-${process.pid}` — keyed on PID ONLY, so two overlapping writes
6
+ * inside the SAME process shared one tmp path. A long write mid-flight gets
7
+ * clobbered by a short write's O_TRUNC open on the same tmp file, and the
8
+ * short write's remaining buffered bytes land past the short write's own
9
+ * EOF; `rename()` is atomic, but the shared tmp file underneath it never was
10
+ * — the publish carries the interleaved bytes.
11
+ *
12
+ * This drives two REAL concurrent async writes (one large enough that
13
+ * Node's fs writeFile chunks it — see WRITE_CHUNK_BYTES below — so the two
14
+ * calls' internal awaits actually interleave on the event loop) against the
15
+ * same target path, and asserts the result is one clean payload with no
16
+ * trailing bytes. Confirmed by hand against the pre-fix pid-only tmp name
17
+ * (reverting just the tmp-path line): this test fails there — the read
18
+ * either doesn't parse or parses to neither payload — and passes against the
19
+ * unique-per-call tmp name shipped in this commit.
20
+ *
21
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ import { test, expect, afterEach } from 'vitest';
27
+ const fs = require('node:fs');
28
+ const fsp = require('node:fs/promises');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const { writeJsonAtomic } = require('../queueStore.cjs');
32
+
33
+ // Node's fs.promises writeFile-style chunking writes large buffers in ~512KB
34
+ // pieces, awaiting each one — large enough here to guarantee the short
35
+ // write's open+truncate+write+rename lands in the middle of the long write's
36
+ // remaining chunks when raced with Promise.all.
37
+ const LONG_PAYLOAD = { kind: 'long', blob: 'A'.repeat(4 * 1024 * 1024) };
38
+ const SHORT_PAYLOAD = { kind: 'short' };
39
+
40
+ const tmpDirs = [];
41
+
42
+ afterEach(async () => {
43
+ while (tmpDirs.length) {
44
+ await fsp.rm(tmpDirs.pop(), { recursive: true, force: true }).catch(() => {});
45
+ }
46
+ });
47
+
48
+ test('two concurrent writeJsonAtomic calls against the same path never interleave', async () => {
49
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'queueStore-atomic-'));
50
+ tmpDirs.push(dir);
51
+ const target = path.join(dir, 'race-target.json');
52
+
53
+ // Repeat a handful of times — the race is timing-dependent, and this
54
+ // guards against a lucky single run masking a reintroduced bug.
55
+ for (let attempt = 0; attempt < 5; attempt++) {
56
+ await Promise.all([
57
+ writeJsonAtomic(target, LONG_PAYLOAD),
58
+ writeJsonAtomic(target, SHORT_PAYLOAD),
59
+ ]);
60
+
61
+ const raw = fs.readFileSync(target, 'utf8');
62
+ const parsed = JSON.parse(raw); // throws if the file is torn — that alone is a failure
63
+ const matchesLong = JSON.stringify(parsed) === JSON.stringify(LONG_PAYLOAD);
64
+ const matchesShort = JSON.stringify(parsed) === JSON.stringify(SHORT_PAYLOAD);
65
+ expect(matchesLong || matchesShort).toBe(true);
66
+
67
+ // No trailing bytes beyond the serialized payload (the exact shape of
68
+ // the real corruption: a complete object followed by an orphaned tail).
69
+ const expected = JSON.stringify(parsed, null, 2);
70
+ expect(raw).toBe(expected);
71
+ }
72
+ });
73
+
74
+ test('a failed write never leaves a stray .tmp-* file behind', async () => {
75
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'queueStore-atomic-fail-'));
76
+ tmpDirs.push(dir);
77
+ const target = path.join(dir, 'target.json');
78
+ // Target is a directory, not a file — mkdir + open + write all succeed,
79
+ // but the final rename() onto an existing directory fails with EISDIR,
80
+ // exercising the unlink-the-tmp-on-error path specifically (not just a
81
+ // trivial pre-write failure).
82
+ fs.mkdirSync(target);
83
+
84
+ await expect(writeJsonAtomic(target, { a: 1 })).rejects.toThrow();
85
+
86
+ const leftovers = fs.readdirSync(dir).filter((f) => f.includes('.tmp-'));
87
+ expect(leftovers).toEqual([]);
88
+ });