claude-code-session-manager 0.80.0 → 0.81.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 (54) hide show
  1. package/dist/assets/{AgentLibrary-zS3jw_1e.js → AgentLibrary-psZYVM2w.js} +1 -1
  2. package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BMied5pg.js} +1 -1
  3. package/dist/assets/{History-C6JRuqfT.js → History-BFC0oaKc.js} +1 -1
  4. package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CTLfO9G8.js} +1 -1
  5. package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-D4I0Cpwn.js} +1 -1
  6. package/dist/assets/{Library-C8JDDliz.js → Library-BTzS8KsS.js} +1 -1
  7. package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CuRmT008.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BgQGtvWo.js} +1 -1
  9. package/dist/assets/{McpServers-BzMv-_84.js → McpServers-DiVQUK57.js} +1 -1
  10. package/dist/assets/{Memory-DSBYQdJR.js → Memory-DSu15JmL.js} +1 -1
  11. package/dist/assets/{Panel-CLUhkNNA.js → Panel-CD5wxGSR.js} +1 -1
  12. package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-C_tAE6yJ.js} +1 -1
  13. package/dist/assets/{Plugins-BKi40jT5.js → Plugins-kwI7W-eK.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-CQceOgsH.js} +1 -1
  15. package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-BDk5e3Pp.js} +1 -1
  16. package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-DRnvEYzv.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-CeifaOlq.js} +1 -1
  18. package/dist/assets/{Settings-Djd8OoBA.js → Settings-BWQ1Utop.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
  20. package/dist/assets/{Skills-D_qAqxZ_.js → Skills-BLwFB0E4.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-DC4ZTArJ.js} +1 -1
  22. package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-pMeGfjUb.js} +1 -1
  23. package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-BHFid2pZ.js} +1 -1
  24. package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-D9eYoZh4.js} +1 -1
  25. package/dist/assets/{index-_2ARyFDj.js → index-CuyM9vAP.js} +4 -4
  26. package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-DrxC67uZ.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  30. package/scripts/project-pages-logic/dist/logic.cjs +12 -12
  31. package/scripts/render-project-pages/dist/renderer.cjs +22 -22
  32. package/src/main/__tests__/rcaReport.test.cjs +24 -0
  33. package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
  34. package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
  35. package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
  36. package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
  37. package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
  38. package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
  39. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
  40. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
  41. package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
  42. package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
  43. package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
  44. package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
  45. package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
  46. package/src/main/lib/branchSweep.cjs +127 -0
  47. package/src/main/lib/gitWorktree.cjs +20 -0
  48. package/src/main/lib/landedSinceRun.cjs +41 -1
  49. package/src/main/lib/rateLimitWindow.cjs +62 -0
  50. package/src/main/lib/rcaReport.cjs +18 -3
  51. package/src/main/lib/reaperHelpers.cjs +169 -3
  52. package/src/main/lib/scheduleJobTransitions.cjs +16 -3
  53. package/src/main/runVerify.cjs +71 -3
  54. package/src/main/scheduler.cjs +741 -31
@@ -33,7 +33,7 @@ const { execFileSync } = require('node:child_process');
33
33
  const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
34
34
  process.env.HOME = tmpHome;
35
35
 
36
- const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
36
+ const { reverifyNeedsReview, computeLooksDone, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
37
37
  const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
38
38
  const { bustCwdCache } = require('../lib/queueStore.cjs');
39
39
 
@@ -51,6 +51,18 @@ function initRepo(dir) {
51
51
  git(['commit', '-q', '-m', 'initial'], dir);
52
52
  }
53
53
 
54
+ // -b main pins the branch name regardless of the host's init.defaultBranch
55
+ // config — findSatisfyingCommitOnMain's tests need a deterministic 'main' ref.
56
+ function initRepoOnMain(dir) {
57
+ fs.mkdirSync(dir, { recursive: true });
58
+ git(['init', '-q', '-b', 'main'], dir);
59
+ git(['config', 'user.email', 'test@example.com'], dir);
60
+ git(['config', 'user.name', 'Test'], dir);
61
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
62
+ git(['add', '-A'], dir);
63
+ git(['commit', '-q', '-m', 'initial'], dir);
64
+ }
65
+
54
66
  function commitFile(dir, relPath, content, message) {
55
67
  const abs = path.join(dir, relPath);
56
68
  fs.mkdirSync(path.dirname(abs), { recursive: true });
@@ -228,6 +240,134 @@ test('computeLooksDone: no declared paths on the PRD → null, never fabricates
228
240
  assert.equal(looksDone, null);
229
241
  });
230
242
 
243
+ test('needs_review row with autoFixAttempted:true is never also stamped looksDone (PRD 1136 — mutually exclusive)', async () => {
244
+ const projectCwd = path.join(tmpHome, 'proj-already-fix-planned');
245
+ initRepo(projectCwd);
246
+ registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
247
+ writePrd(projectCwd, '30-example', [
248
+ '# Implementation notes',
249
+ 'Edit `src/thing.js`.',
250
+ ].join('\n'));
251
+
252
+ // startedAt/finishedAt are fixed well in the past so the real-time commit
253
+ // below lands outside committedInWindow's [startedAt, finishedAt+60s]
254
+ // window — otherwise the pre-existing heal pass would auto-complete the
255
+ // row before ever reaching the stillOpen/looksDone branch this test
256
+ // targets (committedInWindow, unlike computeLooksDone's landedSinceRun,
257
+ // has no upper bound skip).
258
+ const startedAt = '2020-01-01T00:00:00.000Z';
259
+ const queuePath = writeProjectQueue(projectCwd, [
260
+ {
261
+ slug: '30-example',
262
+ status: 'needs_review',
263
+ cwd: projectCwd,
264
+ runId: 'run-30',
265
+ startedAt,
266
+ finishedAt: '2020-01-01T00:05:00.000Z',
267
+ verifierVerdict: 'transcript_errors',
268
+ error: 'transcript_errors: stale verdict',
269
+ // A fix-plan child was already minted for this row at finalize time —
270
+ // the exact ordering that always precedes a later reverifyNeedsReview
271
+ // pass, since autoFixAttempted is stamped synchronously in the same
272
+ // mutate() as the needs_review transition itself.
273
+ autoFixAttempted: true,
274
+ },
275
+ ]);
276
+ writeRunLog('run-30', '30-example', [
277
+ '[scheduler] starting 30-example',
278
+ JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'still broken' }),
279
+ ]);
280
+
281
+ commitFile(projectCwd, 'src/thing.js', 'hello', 'later commit touching the declared path');
282
+
283
+ await reverifyNeedsReview();
284
+
285
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
286
+ assert.equal(jobs.length, 1);
287
+ assert.equal(jobs[0].status, 'needs_review');
288
+ assert.equal(jobs[0].looksDone, undefined, 'a row already fix-planned must never also be stamped looksDone');
289
+ assert.equal(jobs[0].error, 'transcript_errors: stale verdict', 'the looksDone annotation must not overwrite the original error');
290
+ });
291
+
292
+ // findSatisfyingCommitOnMain — PRD 1136's already-satisfied-on-main evidence
293
+ // query. Requires EVERY declared path to have landed on main since
294
+ // queuedAt, not just one — a code-review finding (2026-09-07) confirmed
295
+ // that a PRD citing both its real target file and a second file purely as
296
+ // context (e.g. a `path/to/file.js:123` reference) would otherwise
297
+ // auto-complete unattended off any unrelated commit touching only that
298
+ // second, incidental file.
299
+
300
+ test('findSatisfyingCommitOnMain: single declared path, satisfying commit lands on main → returns the sha', async () => {
301
+ const projectCwd = path.join(tmpHome, 'proj-satisfied-single-path');
302
+ initRepoOnMain(projectCwd);
303
+ registerActiveProject(projectCwd, 'proj-satisfied-single-path-slug');
304
+ writePrd(projectCwd, '40-single-path', [
305
+ '# Implementation notes',
306
+ 'Edit `src/real-work.js`.',
307
+ ].join('\n'));
308
+
309
+ const queuedAt = new Date().toISOString();
310
+ await wait(1100);
311
+ commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
312
+
313
+ const job = { slug: '40-single-path', cwd: projectCwd, queuedAt };
314
+ const commits = await findSatisfyingCommitOnMain(job);
315
+ assert.equal(commits.length, 1);
316
+ });
317
+
318
+ test('findSatisfyingCommitOnMain: two declared paths but only ONE lands → [] (not satisfied — no false completion off an incidental context citation)', async () => {
319
+ const projectCwd = path.join(tmpHome, 'proj-satisfied-partial-path');
320
+ initRepoOnMain(projectCwd);
321
+ registerActiveProject(projectCwd, 'proj-satisfied-partial-path-slug');
322
+ writePrd(projectCwd, '41-partial-path', [
323
+ '# Implementation notes',
324
+ 'Edit `src/real-work.js`. See `src/main/hotfile.js:123` for context.',
325
+ ].join('\n'));
326
+
327
+ const queuedAt = new Date().toISOString();
328
+ await wait(1100);
329
+ // Only the incidentally-cited context file gets a later, UNRELATED commit
330
+ // — the actual target path (src/real-work.js) never lands.
331
+ commitFile(projectCwd, 'src/main/hotfile.js', 'unrelated change', 'unrelated commit');
332
+
333
+ const job = { slug: '41-partial-path', cwd: projectCwd, queuedAt };
334
+ const commits = await findSatisfyingCommitOnMain(job);
335
+ assert.deepEqual(commits, [], 'partial path coverage must never be treated as satisfied');
336
+ });
337
+
338
+ test('findSatisfyingCommitOnMain: two declared paths, BOTH land → returns satisfying commits', async () => {
339
+ const projectCwd = path.join(tmpHome, 'proj-satisfied-both-paths');
340
+ initRepoOnMain(projectCwd);
341
+ registerActiveProject(projectCwd, 'proj-satisfied-both-paths-slug');
342
+ writePrd(projectCwd, '42-both-paths', [
343
+ '# Implementation notes',
344
+ 'Edit `src/real-work.js` and `src/main/hotfile.js`.',
345
+ ].join('\n'));
346
+
347
+ const queuedAt = new Date().toISOString();
348
+ await wait(1100);
349
+ commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
350
+ commitFile(projectCwd, 'src/main/hotfile.js', 'hello', 'fix hotfile too');
351
+
352
+ const job = { slug: '42-both-paths', cwd: projectCwd, queuedAt };
353
+ const commits = await findSatisfyingCommitOnMain(job);
354
+ assert.ok(commits.length >= 1, 'full path coverage must be treated as satisfied');
355
+ });
356
+
357
+ test('findSatisfyingCommitOnMain: no queuedAt on the job → [] without querying git', async () => {
358
+ const projectCwd = path.join(tmpHome, 'proj-no-queued-at');
359
+ initRepoOnMain(projectCwd);
360
+ registerActiveProject(projectCwd, 'proj-no-queued-at-slug');
361
+ writePrd(projectCwd, '43-no-queued-at', [
362
+ '# Implementation notes',
363
+ 'Edit `src/thing.js`.',
364
+ ].join('\n'));
365
+
366
+ const job = { slug: '43-no-queued-at', cwd: projectCwd };
367
+ const commits = await findSatisfyingCommitOnMain(job);
368
+ assert.deepEqual(commits, []);
369
+ });
370
+
231
371
  test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
232
372
  const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
233
373
  writePrd(notARepo, '11-no-repo', [
@@ -0,0 +1,81 @@
1
+ /**
2
+ * scheduler-rate-limit-pause.test.cjs — PRD 1118: pause against the BINDING
3
+ * rate-limit window read off a run's own log, not refreshNextReset()'s
4
+ * five_hour-only assumption, and never leave an indefinite pause with no
5
+ * resume timer when both the log and the billing endpoint come up empty
6
+ * (the live failure mode: the usage endpoint itself 429s).
7
+ *
8
+ * These test the PURE decision functions scheduler.cjs's setPaused() and the
9
+ * spawnJob() rateLimited branch delegate to — never the real setPaused/mutate
10
+ * path, which writes the machine's live scheduler queue.json at a fixed
11
+ * homedir path (~/.claude/session-manager/scheduled-plans) and must not be
12
+ * touched by a test run.
13
+ *
14
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-rate-limit-pause.test.cjs
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ import { test } from 'vitest';
20
+ const assert = require('node:assert/strict');
21
+ const path = require('node:path');
22
+ const {
23
+ resolveRateLimitPauseReset,
24
+ computeEffectiveResumeAt,
25
+ computeResumeDelay,
26
+ } = require('../scheduler.cjs');
27
+
28
+ // Repo-tracked, byte-for-byte copy of the real 2026-09-05 starry-night-ships
29
+ // incident log (see src/main/lib/__tests__/rateLimitWindow.test.cjs for the
30
+ // live-path parity check) — kept hermetic rather than pointed at the
31
+ // machine-local live path directly.
32
+ const FIXTURE = path.join(__dirname, '..', 'lib', '__tests__', 'fixtures', '204-mercury-steam-horse.log.txt');
33
+ const FIVE_HOUR_ISO = new Date(1788643800 * 1000).toISOString(); // what refreshNextReset() would have returned
34
+ const SEVEN_DAY_ISO = new Date(1789059600 * 1000).toISOString(); // the actually-binding window
35
+
36
+ test('resolveRateLimitPauseReset: prefers the log-derived seven-day reset over the billing five_hour reset', () => {
37
+ const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO);
38
+ assert.strictEqual(resetIso, SEVEN_DAY_ISO);
39
+ assert.notStrictEqual(resetIso, FIVE_HOUR_ISO);
40
+ });
41
+
42
+ test('resolveRateLimitPauseReset: falls back to the billing reset when the log yields none', () => {
43
+ const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO);
44
+ assert.strictEqual(resetIso, FIVE_HOUR_ISO);
45
+ });
46
+
47
+ test('resolveRateLimitPauseReset: null when BOTH the log and the billing endpoint are unavailable', () => {
48
+ assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null), null);
49
+ });
50
+
51
+ test('computeEffectiveResumeAt: rate_limit with no resumeAt gets the same bounded 30-minute fallback as network', () => {
52
+ const now = Date.parse('2026-09-05T16:32:00.000Z');
53
+ const resumeAt = computeEffectiveResumeAt('rate_limit', null, now);
54
+ assert.strictEqual(resumeAt, new Date(now + 30 * 60_000).toISOString());
55
+ });
56
+
57
+ test('computeEffectiveResumeAt: an explicit resumeAt (the log-derived reset) is never overridden', () => {
58
+ const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, Date.now());
59
+ assert.strictEqual(resumeAt, SEVEN_DAY_ISO);
60
+ });
61
+
62
+ test('computeEffectiveResumeAt: other reasons (e.g. auth) get no fallback — stays indefinite', () => {
63
+ assert.strictEqual(computeEffectiveResumeAt('auth', null, Date.now()), null);
64
+ });
65
+
66
+ test('computeResumeDelay: a seven-day-out reset (~5 days from now) is armed, not treated as too-far', () => {
67
+ const now = Date.parse('2026-09-05T16:32:00.000Z');
68
+ // ~5 days out — comfortably under the 0x7fffffff (~24.8 day) setTimeout cap.
69
+ const fiveDaysOut = new Date(now + 5 * 24 * 60 * 60_000).toISOString();
70
+ const { delayMs, tooFar } = computeResumeDelay(fiveDaysOut, now);
71
+ assert.strictEqual(tooFar, false);
72
+ assert.ok(delayMs < 0x7fffffff, `expected delay under setTimeout cap, got ${delayMs}`);
73
+ assert.ok(delayMs > 400_000_000, `expected ~5 days in ms, got ${delayMs}`);
74
+ });
75
+
76
+ test('computeResumeDelay: a reset far enough out DOES trip the overflow guard', () => {
77
+ const now = Date.parse('2026-09-05T16:32:00.000Z');
78
+ const thirtyDaysOut = new Date(now + 30 * 24 * 60 * 60_000).toISOString();
79
+ const { tooFar } = computeResumeDelay(thirtyDaysOut, now);
80
+ assert.strictEqual(tooFar, true);
81
+ });
@@ -223,7 +223,11 @@ test('reapDeadRunningJobs salvages a delta-scoped in-place patch (PRD 1098) when
223
223
 
224
224
  const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
225
225
  const row = jobs.find((j) => j.slug === 'vanished-inplace');
226
- assert.equal(row.status, 'completed');
226
+ // PRD 1133: a successful-looking reap that left everything uncommitted
227
+ // (no branch, HEAD never moved) must never read as 'completed' — this is
228
+ // exactly the dangerous shape the reaper now proves against.
229
+ assert.equal(row.status, 'needs_review');
230
+ assert.equal(row.verifierVerdict, 'reaped_without_integration');
227
231
  assert.ok(row.salvagePatch, 'a salvage patch must be recorded on the row');
228
232
  assert.ok(fs.existsSync(row.salvagePatch));
229
233
  const patch = fs.readFileSync(row.salvagePatch, 'utf8');
@@ -332,8 +336,207 @@ test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the r
332
336
 
333
337
  const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
334
338
  const row = jobs.find((j) => j.slug === 'vanished-no-baseline');
335
- assert.equal(row.status, 'completed');
339
+ // PRD 1133: no commit landed (HEAD never moved) — must not read as 'completed'
340
+ // just because the log's own result event looked clean.
341
+ assert.equal(row.status, 'needs_review');
342
+ assert.equal(row.verifierVerdict, 'reaped_without_integration');
336
343
  assert.equal(row.salvagePatch, undefined, 'must skip salvage entirely rather than ever dumping the whole tree');
337
344
  assert.equal(row.leftoverPaths, undefined, 'no baseline means no safe attribution — must not guess');
338
345
  assert.equal(row.leftoverCount, undefined);
339
346
  });
347
+
348
+ // ---------- PRD 1133: reaper must prove integration before 'completed' ----------
349
+
350
+ test('reapDeadRunningJobs: a worktree job shaped like PRD 1118 (branch un-integrated, landedCommit absent) comes out needs_review, naming the branch, and never merges it', async () => {
351
+ const projectCwd = path.join(tmpHome, 'i-project-1118-shape');
352
+ initRepo(projectCwd);
353
+ registerActiveProject(projectCwd);
354
+
355
+ const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
356
+ const slug = '1118-pause-until-the-binding-rate-limit-window';
357
+ const branch = `sm-job/${slug}`;
358
+ // Simulate the job's own worktree branch: one real commit, never merged
359
+ // back into the main tree (exactly the live 1118 shape — "one real commit,
360
+ // +301", stranded on sm-job/1118-... with landedCommit undefined).
361
+ git(['checkout', '-b', branch], projectCwd);
362
+ fs.writeFileSync(path.join(projectCwd, 'rateLimitWindow.cjs'), 'module.exports = {};\n', 'utf8');
363
+ git(['add', '-A'], projectCwd);
364
+ git(['commit', '-q', '-m', 'add rateLimitWindow.cjs'], projectCwd);
365
+ // Back to the original tip (detached is fine — reap only reads guardCwd's
366
+ // current HEAD, it never needs a named branch checked out).
367
+ git(['checkout', headBefore], projectCwd);
368
+
369
+ const runId = `run-${slug}`;
370
+ const queuePath = writeProjectQueue(projectCwd, [
371
+ {
372
+ slug,
373
+ status: 'running',
374
+ cwd: projectCwd,
375
+ runId,
376
+ runtime: { pid: 999999 }, // guaranteed-dead pid
377
+ guardHeadBefore: headBefore,
378
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
379
+ },
380
+ ]);
381
+ writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
382
+
383
+ bustCwdCache();
384
+ await reapDeadRunningJobs();
385
+
386
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
387
+ const row = jobs.find((j) => j.slug === slug);
388
+ assert.equal(row.status, 'needs_review', 'a successful-looking result event must not be enough on its own');
389
+ assert.equal(row.verifierVerdict, 'reaped_without_integration');
390
+ assert.match(row.error, new RegExp(branch.replace('/', '\\/')), 'the parked reason must name the branch that still holds the work');
391
+ assert.equal(row.landedCommit, undefined, 'nothing was actually merged, so landedCommit must stay unset');
392
+
393
+ // The branch itself must survive untouched — this reap must never attempt
394
+ // (let alone perform) the merge.
395
+ const branchStillExists = git(['rev-parse', '--verify', branch], projectCwd).trim();
396
+ assert.ok(branchStillExists, 'the un-integrated branch must be preserved, not merged or deleted, by a mere reap');
397
+ assert.equal(git(['rev-parse', 'HEAD'], projectCwd).trim(), headBefore, 'guardCwd HEAD must not have moved — no merge was performed');
398
+ });
399
+
400
+ test('reapDeadRunningJobs: a worktree job whose branch is already fully integrated into HEAD comes out completed, with landedCommit stamped', async () => {
401
+ const projectCwd = path.join(tmpHome, 'j-project-real-success');
402
+ initRepo(projectCwd);
403
+ registerActiveProject(projectCwd);
404
+
405
+ const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
406
+ const slug = 'real-successful-worktree-run';
407
+ const branch = `sm-job/${slug}`;
408
+ git(['checkout', '-b', branch], projectCwd);
409
+ fs.writeFileSync(path.join(projectCwd, 'feature.txt'), 'shipped\n', 'utf8');
410
+ git(['add', '-A'], projectCwd);
411
+ git(['commit', '-q', '-m', 'ship feature'], projectCwd);
412
+ git(['checkout', headBefore], projectCwd); // back to the pre-branch tip (detached)
413
+ // Simulate a normal completion pass having already integrated the branch
414
+ // (integrateJobBranch's own fast-forward path) BEFORE the process vanished
415
+ // between that merge and the queue write landing.
416
+ git(['merge', '--ff-only', branch], projectCwd);
417
+ const headAfterMerge = git(['rev-parse', 'HEAD'], projectCwd).trim();
418
+
419
+ const runId = `run-${slug}`;
420
+ const queuePath = writeProjectQueue(projectCwd, [
421
+ {
422
+ slug,
423
+ status: 'running',
424
+ cwd: projectCwd,
425
+ runId,
426
+ runtime: { pid: 999999 },
427
+ guardHeadBefore: headBefore,
428
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
429
+ },
430
+ ]);
431
+ writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
432
+
433
+ bustCwdCache();
434
+ await reapDeadRunningJobs();
435
+
436
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
437
+ const row = jobs.find((j) => j.slug === slug);
438
+ assert.equal(row.status, 'completed', 'the branch is already an ancestor of HEAD — the work genuinely landed');
439
+ assert.equal(row.verifierVerdict, undefined);
440
+ assert.equal(row.landedCommit, headAfterMerge, 'HEAD advanced during the run window — landedCommit must be stamped');
441
+ });
442
+
443
+ test('reapDeadRunningJobs: a worktree job whose branch never diverged from its base (legitimate no-op) still completes, not punished', async () => {
444
+ const projectCwd = path.join(tmpHome, 'k-project-noop-worktree');
445
+ initRepo(projectCwd);
446
+ registerActiveProject(projectCwd);
447
+
448
+ const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
449
+ const slug = 'noop-worktree-job';
450
+ const branch = `sm-job/${slug}`;
451
+ // Branch created but nothing ever committed on it — a genuine no-op run.
452
+ git(['branch', branch], projectCwd);
453
+
454
+ const runId = `run-${slug}`;
455
+ const queuePath = writeProjectQueue(projectCwd, [
456
+ {
457
+ slug,
458
+ status: 'running',
459
+ cwd: projectCwd,
460
+ runId,
461
+ runtime: { pid: 999999 },
462
+ guardHeadBefore: headBefore,
463
+ startedAt: new Date(Date.now() - 60_000).toISOString(),
464
+ },
465
+ ]);
466
+ writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
467
+
468
+ bustCwdCache();
469
+ await reapDeadRunningJobs();
470
+
471
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
472
+ const row = jobs.find((j) => j.slug === slug);
473
+ assert.equal(row.status, 'completed', 'a branch with no new commits is a legitimate no-op, not a finish-protocol violation');
474
+ assert.equal(row.verifierVerdict, undefined);
475
+ assert.equal(row.landedCommit, undefined, 'HEAD never moved — nothing to stamp');
476
+ });
477
+
478
+ test('reapDeadRunningJobs: an in-place run in a git repo whose HEAD genuinely advanced during the run window completes, with landedCommit stamped', async () => {
479
+ const projectCwd = path.join(tmpHome, 'l-project-inplace-success');
480
+ initRepo(projectCwd);
481
+ registerActiveProject(projectCwd);
482
+
483
+ const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
484
+ const startedAt = new Date(Date.now() - 60_000).toISOString();
485
+ fs.writeFileSync(path.join(projectCwd, 'shipped.txt'), 'done\n', 'utf8');
486
+ git(['add', '-A'], projectCwd);
487
+ git(['commit', '-q', '-m', 'in-place job commit'], projectCwd);
488
+ const headAfter = git(['rev-parse', 'HEAD'], projectCwd).trim();
489
+
490
+ const slug = 'inplace-job-real-commit';
491
+ const runId = `run-${slug}`;
492
+ const queuePath = writeProjectQueue(projectCwd, [
493
+ {
494
+ slug,
495
+ status: 'running',
496
+ cwd: projectCwd,
497
+ runId,
498
+ runtime: { pid: 999999 },
499
+ guardHeadBefore: headBefore,
500
+ startedAt,
501
+ },
502
+ ]);
503
+ writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
504
+
505
+ bustCwdCache();
506
+ await reapDeadRunningJobs();
507
+
508
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
509
+ const row = jobs.find((j) => j.slug === slug);
510
+ assert.equal(row.status, 'completed');
511
+ assert.equal(row.verifierVerdict, undefined);
512
+ assert.equal(row.landedCommit, headAfter);
513
+ });
514
+
515
+ test('reapDeadRunningJobs: a non-git cwd skips the integration check entirely, preserving today\'s behaviour', async () => {
516
+ const projectCwd = path.join(tmpHome, 'm-project-non-git');
517
+ fs.mkdirSync(projectCwd, { recursive: true });
518
+ registerActiveProject(projectCwd);
519
+
520
+ const slug = 'non-git-cwd-job';
521
+ const runId = `run-${slug}`;
522
+ const queuePath = writeProjectQueue(projectCwd, [
523
+ {
524
+ slug,
525
+ status: 'running',
526
+ cwd: projectCwd,
527
+ runId,
528
+ runtime: { pid: 999999 },
529
+ // No git repo at all — the integration/HEAD-advance check must be
530
+ // skipped, never treated as a failure to prove landing.
531
+ },
532
+ ]);
533
+ writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
534
+
535
+ bustCwdCache();
536
+ await reapDeadRunningJobs();
537
+
538
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
539
+ const row = jobs.find((j) => j.slug === slug);
540
+ assert.equal(row.status, 'completed', 'a non-git cwd must keep today\'s behaviour — the check is meaningless there');
541
+ assert.equal(row.verifierVerdict, undefined);
542
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * branchSweep.test.cjs — PRD 1135: recovering `sm-job/<slug>` branches left
3
+ * stranded (unmerged, owning job row terminal or gone) never gets swept away
4
+ * by hand months later.
5
+ *
6
+ * Exercises the real `git` plumbing against throwaway repos under
7
+ * os.tmpdir() (fast — no network, no Electron).
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/branchSweep.test.cjs
10
+ */
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeEach, afterEach } from 'vitest';
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const { execFileSync } = require('node:child_process');
18
+ const { sweepStrandedJobBranches } = require('../branchSweep.cjs');
19
+
20
+ let tmpRoot;
21
+ let repoCwd;
22
+ let worktreeDir;
23
+
24
+ function git(args, cwd) {
25
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
26
+ }
27
+
28
+ function initRepo(dir) {
29
+ fs.mkdirSync(dir, { recursive: true });
30
+ git(['init', '-q'], dir);
31
+ git(['config', 'user.email', 'test@example.com'], dir);
32
+ git(['config', 'user.name', 'Test'], dir);
33
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
34
+ git(['add', '-A'], dir);
35
+ git(['commit', '-q', '-m', 'initial'], dir);
36
+ }
37
+
38
+ beforeEach(() => {
39
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-'));
40
+ repoCwd = path.join(tmpRoot, 'repo');
41
+ initRepo(repoCwd);
42
+ delete process.env.SM_BRANCH_SWEEP_DISABLE;
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (worktreeDir && fs.existsSync(worktreeDir)) {
47
+ try { git(['worktree', 'remove', '--force', worktreeDir], repoCwd); } catch { /* best effort */ }
48
+ }
49
+ worktreeDir = undefined;
50
+ delete process.env.SM_BRANCH_SWEEP_DISABLE;
51
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
52
+ });
53
+
54
+ /** Create an `sm-job/<slug>` branch with one commit beyond HEAD, without checking it out. */
55
+ function makeStrandedBranch(cwd, slug, fileContents = 'stranded work\n') {
56
+ const branch = `sm-job/${slug}`;
57
+ git(['branch', branch], cwd);
58
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-checkout-'));
59
+ git(['worktree', 'add', '-q', tmpCheckout, branch], cwd);
60
+ fs.writeFileSync(path.join(tmpCheckout, `${slug}.txt`), fileContents, 'utf8');
61
+ git(['add', '-A'], tmpCheckout);
62
+ git(['commit', '-q', '-m', `job ${slug} work`], tmpCheckout);
63
+ git(['worktree', 'remove', '--force', tmpCheckout], cwd);
64
+ return branch;
65
+ }
66
+
67
+ test('stranded branch with a terminal job row is integrated', async () => {
68
+ const branch = makeStrandedBranch(repoCwd, 'alpha');
69
+ const jobs = [{ slug: 'alpha', status: 'completed' }];
70
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
71
+ const r = results.find((x) => x.branch === branch);
72
+ expect(r.action).toBe('integrated');
73
+ expect(fs.existsSync(path.join(repoCwd, 'alpha.txt'))).toBe(true);
74
+ });
75
+
76
+ test('stranded branch with NO job row at all is also integrated', async () => {
77
+ const branch = makeStrandedBranch(repoCwd, 'ghost');
78
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs: [], attemptedBranches: new Set() });
79
+ const r = results.find((x) => x.branch === branch);
80
+ expect(r.action).toBe('integrated');
81
+ });
82
+
83
+ test('branch already merged into HEAD is a silent no-op', async () => {
84
+ const branch = makeStrandedBranch(repoCwd, 'bravo');
85
+ git(['merge', '--no-ff', '--no-edit', branch], repoCwd);
86
+ const jobs = [{ slug: 'bravo', status: 'completed' }];
87
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
88
+ const r = results.find((x) => x.branch === branch);
89
+ expect(r.action).toBe('no-op-merged');
90
+ });
91
+
92
+ test('branch checked out in a live worktree is skipped entirely', async () => {
93
+ const branch = makeStrandedBranch(repoCwd, 'charlie');
94
+ worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-live-'));
95
+ git(['worktree', 'add', '-q', worktreeDir, branch], repoCwd);
96
+ const jobs = [{ slug: 'charlie', status: 'running' }];
97
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
98
+ const r = results.find((x) => x.branch === branch);
99
+ expect(r.action).toBe('skip-checked-out');
100
+ });
101
+
102
+ test('branch owned by a NON-terminal job row is left alone, never integrated', async () => {
103
+ const branch = makeStrandedBranch(repoCwd, 'delta');
104
+ const jobs = [{ slug: 'delta', status: 'needs_review' }];
105
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
106
+ const r = results.find((x) => x.branch === branch);
107
+ expect(r.action).toBe('skip-active-row');
108
+ expect(fs.existsSync(path.join(repoCwd, 'delta.txt'))).toBe(false);
109
+ });
110
+
111
+ test('conflicting branch is flagged, never forced, and the branch is never deleted', async () => {
112
+ // Same path committed differently on both HEAD and the branch → real conflict.
113
+ fs.writeFileSync(path.join(repoCwd, 'shared.txt'), 'main tree version\n', 'utf8');
114
+ git(['add', '-A'], repoCwd);
115
+ git(['commit', '-q', '-m', 'main tree edits shared.txt'], repoCwd);
116
+
117
+ const branch = 'sm-job/echo';
118
+ git(['branch', branch, 'HEAD~1'], repoCwd);
119
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-conflict-'));
120
+ git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
121
+ fs.writeFileSync(path.join(tmpCheckout, 'shared.txt'), 'branch version — conflicts\n', 'utf8');
122
+ git(['add', '-A'], tmpCheckout);
123
+ git(['commit', '-q', '-m', 'job echo edits shared.txt'], tmpCheckout);
124
+ git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
125
+
126
+ const jobs = [{ slug: 'echo', status: 'failed' }];
127
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
128
+ const r = results.find((x) => x.branch === branch);
129
+ expect(r.action).toBe('conflict');
130
+ expect(r.integration.ok).toBe(false);
131
+ // Branch itself must survive a conflict, untouched.
132
+ const stillExists = git(['rev-parse', '--verify', branch], repoCwd).trim();
133
+ expect(stillExists.length).toBe(40);
134
+ });
135
+
136
+ test('a branch is retried at most once per process lifetime via the shared attempted set', async () => {
137
+ fs.writeFileSync(path.join(repoCwd, 'shared2.txt'), 'main\n', 'utf8');
138
+ git(['add', '-A'], repoCwd);
139
+ git(['commit', '-q', '-m', 'main edits shared2'], repoCwd);
140
+ const branch = 'sm-job/foxtrot';
141
+ git(['branch', branch, 'HEAD~1'], repoCwd);
142
+ const tmpCheckout = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-branchsweep-retry-'));
143
+ git(['worktree', 'add', '-q', tmpCheckout, branch], repoCwd);
144
+ fs.writeFileSync(path.join(tmpCheckout, 'shared2.txt'), 'branch — conflicts\n', 'utf8');
145
+ git(['add', '-A'], tmpCheckout);
146
+ git(['commit', '-q', '-m', 'job foxtrot edits shared2'], tmpCheckout);
147
+ git(['worktree', 'remove', '--force', tmpCheckout], repoCwd);
148
+
149
+ const jobs = [{ slug: 'foxtrot', status: 'failed' }];
150
+ const attemptedBranches = new Set();
151
+ const first = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
152
+ expect(first.results.find((x) => x.branch === branch).action).toBe('conflict');
153
+ const second = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches });
154
+ expect(second.results.find((x) => x.branch === branch).action).toBe('skip-already-attempted');
155
+ });
156
+
157
+ test('SM_BRANCH_SWEEP_DISABLE=1 restores today\'s behaviour exactly — no scan, no integration', async () => {
158
+ makeStrandedBranch(repoCwd, 'golf');
159
+ process.env.SM_BRANCH_SWEEP_DISABLE = '1';
160
+ const jobs = [{ slug: 'golf', status: 'completed' }];
161
+ const { results } = await sweepStrandedJobBranches({ cwd: repoCwd, jobs, attemptedBranches: new Set() });
162
+ expect(results).toEqual([]);
163
+ expect(fs.existsSync(path.join(repoCwd, 'golf.txt'))).toBe(false);
164
+ });