claude-code-session-manager 0.83.0 → 0.85.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.
- package/dist/assets/{AgentLibrary-B-OM-4gk.js → AgentLibrary-Bkv-HcP1.js} +1 -1
- package/dist/assets/{DataModel-BC6ppcBr.js → DataModel-DRH-Ty20.js} +1 -1
- package/dist/assets/{History-D3Qw2BWn.js → History-CfRhT1Im.js} +2 -2
- package/dist/assets/{Hooks-OxBQ0nIc.js → Hooks-wEmh_U6c.js} +1 -1
- package/dist/assets/{HostBilko-BNFC6bNZ.js → HostBilko-D_t7Rbi7.js} +1 -1
- package/dist/assets/{Library-DAMyrUtY.js → Library-CpArQ-OJ.js} +1 -1
- package/dist/assets/{ListDetail-BwywrpvE.js → ListDetail-pjaKYs84.js} +1 -1
- package/dist/assets/{MarkdownEditor-HEwjN1bp.js → MarkdownEditor-Xc141kjj.js} +1 -1
- package/dist/assets/{McpServers-D1VZF7Ix.js → McpServers-ftqaV3kn.js} +1 -1
- package/dist/assets/{Memory-CgYJ_QqD.js → Memory-ChMWkNd0.js} +6 -6
- package/dist/assets/{Panel-dTNWC59Q.js → Panel-D9Kr40Ai.js} +1 -1
- package/dist/assets/{Permissions-qI_NKi1b.js → Permissions-DKoNVgzj.js} +1 -1
- package/dist/assets/{Plugins-DZ0_CHiF.js → Plugins-BtChISho.js} +2 -2
- package/dist/assets/{ProvenanceBadge-B2i_uxCu.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
- package/dist/assets/{SaveBar-4tNMCjUd.js → SaveBar-I0_dWNTX.js} +1 -1
- package/dist/assets/{Scheduler-CUen18Gc.js → Scheduler-CbES7MC8.js} +7 -7
- package/dist/assets/{ScopeSwitcher-Co3tkLTT.js → ScopeSwitcher-5GTEveb2.js} +1 -1
- package/dist/assets/Settings-BX3FElXk.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BKcHtMPc.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
- package/dist/assets/{Skills-DHb4INtF.js → Skills-DJB6-bBM.js} +2 -2
- package/dist/assets/SystemPrompt-BiDDrJUA.js +1 -0
- package/dist/assets/{TagLibrary-xLP5dJvh.js → TagLibrary-_Wrevtop.js} +1 -1
- package/dist/assets/{TiptapBody-T-ULTjK4.js → TiptapBody-OWWXdLRy.js} +1 -1
- package/dist/assets/{Toggle-B-5M2hGe.js → Toggle-B122N0HL.js} +1 -1
- package/dist/assets/{index-CYhdtisq.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-CzAxC432.js → index-DhvuQL4C.js} +316 -314
- package/dist/assets/{settingsSchema-C_nsemcF.js → settingsSchema-sGoCTd7J.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -1
- package/scripts/hooks/guard-destructive-git.cjs +514 -0
- package/scripts/hooks/guard-inline-implementation.cjs +219 -0
- package/scripts/hooks/guard-prd-writes.cjs +200 -0
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +64 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +84 -0
- package/src/main/__tests__/health-usage-poller.test.cjs +97 -0
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +65 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +149 -0
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +120 -0
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +143 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +79 -0
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +72 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +74 -0
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +58 -0
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +1 -0
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +134 -2
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +33 -0
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +136 -0
- package/src/main/__tests__/telemetryClient.test.cjs +242 -1
- package/src/main/__tests__/telemetryContract.test.cjs +930 -0
- package/src/main/crashDiagnostics.cjs +29 -1
- package/src/main/health.cjs +197 -3
- package/src/main/index.cjs +65 -6
- package/src/main/ipcSchemas.cjs +19 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +103 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +302 -4
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +34 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +413 -1
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +71 -0
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +88 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +123 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +58 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +626 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +123 -0
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +136 -0
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +57 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +95 -0
- package/src/main/lib/crashTelemetry.cjs +37 -0
- package/src/main/lib/delegationReadiness.cjs +119 -1
- package/src/main/lib/epicMint.cjs +2 -0
- package/src/main/lib/gitWorktree.cjs +427 -17
- package/src/main/lib/jobWorktree.cjs +2 -1
- package/src/main/lib/jobWorktreeBootLive.cjs +51 -0
- package/src/main/lib/jobWorktreeTerminalOrphanLive.cjs +68 -0
- package/src/main/lib/opsErrorLog.cjs +78 -25
- package/src/main/lib/queueStore.cjs +241 -22
- package/src/main/lib/reaperHelpers.cjs +23 -1
- package/src/main/lib/scheduleJobSchema.cjs +7 -0
- package/src/main/lib/scheduleJobTransitions.cjs +12 -0
- package/src/main/lib/telemetryBacklog.cjs +601 -0
- package/src/main/lib/telemetryBoot.cjs +76 -0
- package/src/main/lib/telemetryClient.cjs +150 -5
- package/src/main/lib/telemetryConsent.cjs +34 -0
- package/src/main/lib/telemetryCounters.cjs +45 -0
- package/src/main/promptSessionTranscript.cjs +0 -0
- package/src/main/pty.cjs +2 -0
- package/src/main/scheduler.cjs +481 -33
- package/src/preload/api.d.ts +84 -4
- package/src/preload/index.cjs +9 -0
- package/dist/assets/Settings-DXVgKLUx.js +0 -3
- 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
|
+
});
|