claude-code-session-manager 0.86.0 → 0.87.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 (128) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
  27. package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +14 -14
  30. package/plugins/CLAUDE.md +61 -0
  31. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  32. package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
  33. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
  34. package/scripts/scheduler-mcp-server.cjs +7 -0
  35. package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
  36. package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
  37. package/src/main/__tests__/epicMint.test.cjs +2 -2
  38. package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
  39. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  40. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  41. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  42. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  43. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  44. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  45. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  46. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  47. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  48. package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
  49. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  50. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  51. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  52. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  53. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  54. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  55. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  56. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  57. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  58. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  59. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  60. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  61. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  62. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +14 -4
  63. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  64. package/src/main/chatRunner.cjs +8 -5
  65. package/src/main/health.cjs +1 -1
  66. package/src/main/historyAggregator.cjs +5 -0
  67. package/src/main/index.cjs +95 -44
  68. package/src/main/ipcSchemas.cjs +47 -0
  69. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  70. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  71. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  72. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  73. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  74. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  75. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  76. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  77. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  78. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  79. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  80. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  81. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  82. package/src/main/lib/agentModelResolve.cjs +65 -27
  83. package/src/main/lib/bootSelfHeal.cjs +88 -0
  84. package/src/main/lib/delegationReadiness.cjs +290 -225
  85. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  86. package/src/main/lib/ephemeralCwd.cjs +1 -1
  87. package/src/main/lib/epicMint.cjs +3 -3
  88. package/src/main/lib/gitWorktree.cjs +42 -12
  89. package/src/main/lib/guardShims.cjs +156 -0
  90. package/src/main/lib/jobDirtFilter.cjs +7 -2
  91. package/src/main/lib/launchFailure.cjs +2 -1
  92. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  93. package/src/main/lib/needsReviewLedger.cjs +205 -0
  94. package/src/main/lib/opsErrorLog.cjs +1 -1
  95. package/src/main/lib/opsOwnership.cjs +1 -1
  96. package/src/main/lib/prdCreate.cjs +56 -1
  97. package/src/main/lib/prdDisposition.cjs +199 -0
  98. package/src/main/lib/prdFrontmatter.cjs +8 -2
  99. package/src/main/lib/prdLocations.cjs +167 -45
  100. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  101. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  102. package/src/main/lib/projectRootResolve.cjs +1 -1
  103. package/src/main/lib/queueHistory.cjs +19 -1
  104. package/src/main/lib/queueStore.cjs +6 -1
  105. package/src/main/lib/reaperHelpers.cjs +181 -15
  106. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  107. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  108. package/src/main/lib/schedulerConfig.cjs +24 -0
  109. package/src/main/lib/usageCircuit.cjs +159 -0
  110. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  111. package/src/main/scheduler/prdParser.cjs +13 -0
  112. package/src/main/scheduler.cjs +1285 -143
  113. package/src/main/templates/PRD_AUTHORING.md +50 -0
  114. package/src/main/templates/project-pages-catalog.json +1 -1
  115. package/src/main/usage.cjs +21 -3
  116. package/src/preload/api.d.ts +92 -1
  117. package/src/preload/index.cjs +10 -0
  118. package/web/README.md +41 -0
  119. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  120. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  121. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  122. package/dist/assets/AgentLibrary-DTFL7y8G.js +0 -3
  123. package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
  124. package/dist/assets/Permissions-BiYZNGYW.js +0 -3
  125. package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
  126. package/dist/assets/Settings-Cv-pRyms.js +0 -3
  127. package/dist/assets/settingsSchema-BJVciriw.js +0 -3
  128. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * scheduler-job-budget.test.cjs — per-job wall-clock budget derived from the
3
+ * PRD's own `estimateMinutes`, and the classifier that parks an over-budget
4
+ * job `needs_review` (never 'failed', never silently 'completed').
5
+ *
6
+ * Closes the gap `findOverrunningJobs` deliberately leaves open (escalation
7
+ * only, never kills — see its own header): p50/p90/max across 606 run-meta
8
+ * records since 2026-08-25 was 13m/60m/240m against a concurrencyCap of 4,
9
+ * so a chatty-but-runaway executor that keeps emitting output (never
10
+ * tripping IDLE_OUTPUT_KILL_MS) could hold a dispatch slot for hours with
11
+ * nothing to notice or act on it.
12
+ *
13
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-job-budget.test.cjs
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ // vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
19
+ import { test } from 'vitest';
20
+ const assert = require('node:assert/strict');
21
+ const {
22
+ computeJobBudgetMs,
23
+ classifyBudgetKill,
24
+ isJobBudgetExempt,
25
+ shouldKillForBudget,
26
+ resolveBudgetKillOutcome,
27
+ selectAutoFixTargets,
28
+ JOB_BUDGET_FACTOR,
29
+ JOB_BUDGET_FLOOR_MS,
30
+ JOB_BUDGET_CEILING_MS,
31
+ } = require('../scheduler.cjs');
32
+
33
+ // ---------- computeJobBudgetMs: both clamps ----------
34
+
35
+ test('computeJobBudgetMs: a mid-size estimate is factor*estimate, no clamp hit', () => {
36
+ // 30m * 3 = 90m — comfortably between the 45m floor and the 180m ceiling.
37
+ const budgetMs = computeJobBudgetMs(30);
38
+ assert.strictEqual(budgetMs, 90 * 60_000);
39
+ });
40
+
41
+ test('computeJobBudgetMs: the floor clamp — a tiny/missing/zero estimate never goes below 45m', () => {
42
+ // 5m * 3 = 15m, well under the 45m floor.
43
+ assert.strictEqual(computeJobBudgetMs(5), JOB_BUDGET_FLOOR_MS);
44
+ // Missing/zero estimate is treated as 0 — the floor dominates, no special-casing.
45
+ assert.strictEqual(computeJobBudgetMs(0), JOB_BUDGET_FLOOR_MS);
46
+ assert.strictEqual(computeJobBudgetMs(null), JOB_BUDGET_FLOOR_MS);
47
+ assert.strictEqual(computeJobBudgetMs(undefined), JOB_BUDGET_FLOOR_MS);
48
+ });
49
+
50
+ test('computeJobBudgetMs: the ceiling clamp — a huge estimate never exceeds 180m', () => {
51
+ // 200m * 3 = 600m, way past the 180m ceiling.
52
+ assert.strictEqual(computeJobBudgetMs(200), JOB_BUDGET_CEILING_MS);
53
+ });
54
+
55
+ test('computeJobBudgetMs: honors caller overrides when neither clamp is hit', () => {
56
+ const budgetMs = computeJobBudgetMs(5, { factor: 2, floorMs: 1000, ceilingMs: 3_600_000 });
57
+ assert.strictEqual(budgetMs, 10 * 60_000);
58
+ });
59
+
60
+ test('JOB_BUDGET_FACTOR/FLOOR/CEILING are the named constants the AC requires (3x, 45m, 180m)', () => {
61
+ assert.strictEqual(JOB_BUDGET_FACTOR, 3);
62
+ assert.strictEqual(JOB_BUDGET_FLOOR_MS, 45 * 60_000);
63
+ assert.strictEqual(JOB_BUDGET_CEILING_MS, 180 * 60_000);
64
+ });
65
+
66
+ // ---------- shouldKillForBudget: under vs over budget ----------
67
+
68
+ test('shouldKillForBudget: a job under budget is untouched', () => {
69
+ assert.strictEqual(shouldKillForBudget(44 * 60_000, 45 * 60_000), false);
70
+ });
71
+
72
+ test('shouldKillForBudget: a job at or past its budget fires', () => {
73
+ assert.strictEqual(shouldKillForBudget(45 * 60_000, 45 * 60_000), true);
74
+ assert.strictEqual(shouldKillForBudget(241 * 60_000, 90 * 60_000), true);
75
+ });
76
+
77
+ // ---------- classifyBudgetKill: the needs_review park + evidence threading ----------
78
+
79
+ test('classifyBudgetKill: a job not killed by the budget watchdog is untouched (returns null)', () => {
80
+ assert.strictEqual(classifyBudgetKill({ killedByWatchdog: 'idle-tail', exitCode: 143 }, null), null);
81
+ assert.strictEqual(classifyBudgetKill({ killedByWatchdog: null, exitCode: 0 }, null), null);
82
+ assert.strictEqual(classifyBudgetKill(null, null), null);
83
+ });
84
+
85
+ test('classifyBudgetKill: an over-budget job parks needs_review with the exact reason string, no evidence', () => {
86
+ const res = {
87
+ killedByWatchdog: 'budget',
88
+ exitCode: 143,
89
+ budgetKillReason: 'wall-clock budget exceeded: ran 241m against a 90m budget (estimateMinutes=30)',
90
+ };
91
+ const decision = classifyBudgetKill(res, null);
92
+ assert.strictEqual(decision.status, 'needs_review');
93
+ assert.strictEqual(decision.reason, 'wall-clock budget exceeded: ran 241m against a 90m budget (estimateMinutes=30)');
94
+ assert.strictEqual(decision.landedCommit, null);
95
+ });
96
+
97
+ test('classifyBudgetKill: an over-budget job with a verified landed commit is still adjudicated on git evidence, not discarded', () => {
98
+ const res = {
99
+ killedByWatchdog: 'budget',
100
+ exitCode: 143,
101
+ budgetKillReason: 'wall-clock budget exceeded: ran 91m against a 90m budget (estimateMinutes=30)',
102
+ };
103
+ const decision = classifyBudgetKill(res, 'deadbeef1234567890');
104
+ // Never 'failed' or 'completed' just because a commit landed — still parked
105
+ // for review, but the evidence is threaded onto the row rather than dropped.
106
+ assert.strictEqual(decision.status, 'needs_review');
107
+ assert.strictEqual(decision.landedCommit, 'deadbeef1234567890');
108
+ });
109
+
110
+ // ---------- isJobBudgetExempt: quietMachine + explicit opt-out ----------
111
+
112
+ test('isJobBudgetExempt: a quietMachine job is exempt', () => {
113
+ assert.strictEqual(isJobBudgetExempt({ quietMachine: true }), true);
114
+ });
115
+
116
+ test('isJobBudgetExempt: a PRD with an explicit budgetExempt opt-out is exempt', () => {
117
+ assert.strictEqual(isJobBudgetExempt({ budgetExempt: true }), true);
118
+ });
119
+
120
+ test('isJobBudgetExempt: an ordinary job is not exempt', () => {
121
+ assert.strictEqual(isJobBudgetExempt({}), false);
122
+ assert.strictEqual(isJobBudgetExempt({ quietMachine: false, budgetExempt: false }), false);
123
+ });
124
+
125
+ // ---------- resolveBudgetKillOutcome: the watchdog-tick vs natural-exit race ----------
126
+
127
+ test('resolveBudgetKillOutcome: a genuine signal kill (exit 143) is trusted', () => {
128
+ const outcome = resolveBudgetKillOutcome({
129
+ killedByWatchdog: 'budget', killedBySignal: true, durationMs: 91 * 60_000, jobBudgetMs: 90 * 60_000, estimateMinutes: 30,
130
+ });
131
+ assert.strictEqual(outcome.killedByWatchdog, 'budget');
132
+ assert.strictEqual(outcome.budgetKillReason, 'wall-clock budget exceeded: ran 91m against a 90m budget (estimateMinutes=30)');
133
+ });
134
+
135
+ test('resolveBudgetKillOutcome: a race where the child exits cleanly (exit 0) in the same tick is NOT trusted as a budget kill', () => {
136
+ // The watchdog's action() fired (stamping ctx.killedByWatchdog='budget')
137
+ // but the agent had already exited on its own with exit 0 — killTree()
138
+ // was a no-op against an already-dead pid. Must never misclassify a
139
+ // genuinely successful run as needs_review.
140
+ const outcome = resolveBudgetKillOutcome({
141
+ killedByWatchdog: 'budget', killedBySignal: false, durationMs: 90 * 60_000, jobBudgetMs: 90 * 60_000, estimateMinutes: 30,
142
+ });
143
+ assert.strictEqual(outcome.killedByWatchdog, null);
144
+ assert.strictEqual(outcome.budgetKillReason, null);
145
+ });
146
+
147
+ test('resolveBudgetKillOutcome: a race where the child fails on its own (exit 1, unrelated) is NOT trusted as a budget kill', () => {
148
+ const outcome = resolveBudgetKillOutcome({
149
+ killedByWatchdog: 'budget', killedBySignal: false, durationMs: 90 * 60_000, jobBudgetMs: 90 * 60_000, estimateMinutes: 30,
150
+ });
151
+ assert.strictEqual(outcome.killedByWatchdog, null);
152
+ });
153
+
154
+ test('resolveBudgetKillOutcome: no watchdog fired at all — untouched', () => {
155
+ const outcome = resolveBudgetKillOutcome({
156
+ killedByWatchdog: 'idle-tail', killedBySignal: true, durationMs: 25 * 60_000, jobBudgetMs: 90 * 60_000, estimateMinutes: 30,
157
+ });
158
+ assert.strictEqual(outcome.killedByWatchdog, 'idle-tail');
159
+ assert.strictEqual(outcome.budgetKillReason, null);
160
+ });
161
+
162
+ // ---------- selectAutoFixTargets: a budget-killed job never gets auto-retried ----------
163
+
164
+ test('selectAutoFixTargets: a budget_exceeded needs_review job is excluded — it parks for a human/ladder decision, never a fix-plan', () => {
165
+ const jobs = [
166
+ { slug: '10-budget-victim', status: 'needs_review', verifierVerdict: 'budget_exceeded', runId: 'r1' },
167
+ { slug: '11-ordinary', status: 'needs_review', verifierVerdict: 'transcript_errors', runId: 'r2' },
168
+ ];
169
+ const targets = selectAutoFixTargets(jobs, { fixSlugExists: () => false });
170
+ assert.ok(!targets.some((t) => t.slug === '10-budget-victim'));
171
+ assert.ok(targets.some((t) => t.slug === '11-ordinary'));
172
+ });
@@ -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, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
36
+ const { reverifyNeedsReview, computeLooksDone, applyNeedsReviewAutoResolve, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
37
37
  const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
38
38
  const { bustCwdCache } = require('../lib/queueStore.cjs');
39
39
 
@@ -127,7 +127,10 @@ test('failed + no result event (unverified-shaped) + later commit touching decla
127
127
  writeRunLog('run-171', '171-example', ['[scheduler] starting 171-example']); // no result event
128
128
 
129
129
  await wait(1100); // git --since has 1s resolution
130
- commitFile(projectCwd, 'src/foo.js', 'hello', 'fix 171');
130
+ // Must name this job's own slug to be attributable (see
131
+ // attributeLandedCommits's 'slug trailer' rule) — path overlap alone is no
132
+ // longer evidence.
133
+ commitFile(projectCwd, 'src/foo.js', 'hello', 'fix 171-example');
131
134
 
132
135
  await reverifyNeedsReview();
133
136
 
@@ -136,8 +139,9 @@ test('failed + no result event (unverified-shaped) + later commit touching decla
136
139
  assert.equal(jobs[0].status, 'needs_review', 'looksDone never auto-completes — only surfaces for a human');
137
140
  assert.ok(jobs[0].looksDone, 'expected a looksDone annotation');
138
141
  assert.equal(jobs[0].looksDone.commits.length, 1);
142
+ assert.equal(jobs[0].looksDone.rule, 'slug trailer');
139
143
  assert.deepEqual(jobs[0].looksDone.paths, ['src/foo.js']);
140
- assert.match(jobs[0].error, /looks done — 1 commit/);
144
+ assert.match(jobs[0].error, /looks done \(slug trailer\) — 1 commit/);
141
145
  });
142
146
 
143
147
  test('failed + a real result event (genuine gate failure) → not a candidate, stays failed untouched', async () => {
@@ -379,3 +383,89 @@ test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)'
379
383
  const looksDone = await computeLooksDone(job);
380
384
  assert.equal(looksDone, null);
381
385
  });
386
+
387
+ // --- Attribution regression (PRD 1204/1205 incident, 2026-09-13) ---
388
+ //
389
+ // Two PRDs in the SAME Epic declaring the same hot file: sibling job B lands
390
+ // a commit on that path while job A is parked in needs_review. Path overlap
391
+ // alone must never read as job A's own evidence — that's exactly how 1204's
392
+ // row cited 1205's commit 5dadf3c as "looks done" while 1204's real
393
+ // implementing commit (164184e) sat unnoticed.
394
+
395
+ test('sibling PRD commit on the same declared path is not attributable — computeLooksDone returns null, applyNeedsReviewAutoResolve never promotes to completed', async () => {
396
+ const projectCwd = path.join(tmpHome, 'proj-1204-sibling-incident');
397
+ initRepo(projectCwd);
398
+ registerActiveProject(projectCwd, 'proj-1204-sibling-incident-slug');
399
+ writePrd(projectCwd, '1204-job-a', [
400
+ '# Implementation notes',
401
+ 'Edit `src/main/scheduler.cjs`.',
402
+ ].join('\n'));
403
+
404
+ const startedAt = new Date().toISOString();
405
+ await wait(1100);
406
+ // Sibling job B's commit: touches job A's declared path, but names neither
407
+ // job A's slug nor any `sm-job/1204-job-a` branch, and job A's row carries
408
+ // no landedCommit of its own.
409
+ commitFile(projectCwd, 'src/main/scheduler.cjs', 'sibling change', 'feat(scheduler): restructure the backlog into Epic groups (1205-job-b)');
410
+
411
+ const jobA = {
412
+ slug: '1204-job-a',
413
+ cwd: projectCwd,
414
+ status: 'needs_review',
415
+ startedAt,
416
+ autoFixAttempted: true,
417
+ autoFixOutcome: 'no-plan',
418
+ autoFixRetries: 1,
419
+ exhaustedResolveAttempts: 0,
420
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * 60_000).toISOString() }],
421
+ };
422
+
423
+ const looksDone = await computeLooksDone(jobA);
424
+ assert.equal(looksDone, null, 'a sibling\'s path-overlapping commit must never be credited as job A\'s own evidence');
425
+
426
+ const outcome = applyNeedsReviewAutoResolve(jobA);
427
+ assert.equal(outcome, 'requeued', 'with no attributable evidence, the row must fall through to the existing requeue path, never complete');
428
+ assert.equal(jobA.status, 'pending');
429
+ });
430
+
431
+ test('this job\'s OWN attributable commit (landedCommit) still produces looksDone and still promotes to completed', async () => {
432
+ const projectCwd = path.join(tmpHome, 'proj-1204-own-commit');
433
+ initRepo(projectCwd);
434
+ registerActiveProject(projectCwd, 'proj-1204-own-commit-slug');
435
+ writePrd(projectCwd, '1204-job-a2', [
436
+ '# Implementation notes',
437
+ 'Edit `src/main/scheduler.cjs`.',
438
+ ].join('\n'));
439
+
440
+ const startedAt = new Date().toISOString();
441
+ await wait(1100);
442
+ // A sibling commit lands on the same path first...
443
+ commitFile(projectCwd, 'src/main/scheduler.cjs', 'sibling change', 'feat(scheduler): unrelated sibling work');
444
+ // ...then this job's OWN commit lands, exactly as spawnJob's finalize step
445
+ // would have recorded it on job.landedCommit.
446
+ commitFile(projectCwd, 'src/main/scheduler.cjs', 'this job\'s own change', 'fix(scheduler): attribution fix');
447
+ const ownSha = git(['rev-parse', 'HEAD'], projectCwd).trim();
448
+
449
+ const jobA = {
450
+ slug: '1204-job-a2',
451
+ cwd: projectCwd,
452
+ status: 'needs_review',
453
+ startedAt,
454
+ landedCommit: ownSha,
455
+ autoFixAttempted: true,
456
+ autoFixOutcome: 'no-plan',
457
+ autoFixRetries: 1,
458
+ exhaustedResolveAttempts: 0,
459
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * 60_000).toISOString() }],
460
+ };
461
+
462
+ const looksDone = await computeLooksDone(jobA);
463
+ assert.ok(looksDone, 'this job\'s own landedCommit must be recognized as attributable evidence');
464
+ assert.equal(looksDone.rule, 'landedCommit');
465
+ assert.deepEqual(looksDone.commits, [ownSha], 'only this job\'s own commit is evidence — never the sibling\'s');
466
+
467
+ jobA.looksDone = looksDone;
468
+ const outcome = applyNeedsReviewAutoResolve(jobA);
469
+ assert.equal(outcome, 'completed', 'strong, attributable evidence must still promote the row');
470
+ assert.equal(jobA.status, 'completed');
471
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * scheduler-porcelain-rename.test.cjs — PRD 1187/1186-follow-on: a staged
3
+ * rename ("R old -> new") was parsed by `parsePorcelainEntries` as ONE fused
4
+ * path string `"old -> new"`. That string can never match a real path in
5
+ * `dirtyAfter`, `pathsCommittedDuringRun`, or an `fs.existsSync` check, so
6
+ * every staged rename in a shared-tree-guard baseline was structurally
7
+ * guaranteed to be reported reverted — even when it was cleanly committed
8
+ * during the run. On 2026-09-12 this flagged 45 paths and parked a clean,
9
+ * committed run (job `1187-autoresolve-guard-verdict-parks`):
10
+ *
11
+ * "job discarded pre-existing state in the shared tree: 45 path(s)
12
+ * reverted with no commit to explain it
13
+ * (scripts/__tests__/manual-chapter-links.test.cjs ->
14
+ * web/manual/__tests__/chapter-links.test.cjs, ...)" — exit 0, landedCommit
15
+ * recorded, nothing actually lost.
16
+ *
17
+ * Every test below is written to FAIL against the pre-fix implementation
18
+ * (`path: l.slice(3)`, no rename/quote parsing at all).
19
+ *
20
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-porcelain-rename.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect } from 'vitest';
26
+ const scheduler = require('../scheduler.cjs');
27
+ const { parsePorcelainEntries, evaluateSharedTreeGuard } = scheduler;
28
+
29
+ // --- parsePorcelainEntries: rename/copy lines split into real components ---
30
+
31
+ test('parsePorcelainEntries: a plain staged rename splits into new path + oldPath, not one fused string', () => {
32
+ const entries = parsePorcelainEntries('R old.txt -> new.txt\n');
33
+ expect(entries).toEqual([{ code: 'R ', path: 'new.txt', oldPath: 'old.txt' }]);
34
+ });
35
+
36
+ test('parsePorcelainEntries: a rename-with-worktree-modification (RM) line still splits correctly', () => {
37
+ const entries = parsePorcelainEntries('RM old.txt -> new.txt\n');
38
+ expect(entries).toEqual([{ code: 'RM', path: 'new.txt', oldPath: 'old.txt' }]);
39
+ });
40
+
41
+ test('parsePorcelainEntries: a copy line (C) splits the same way as a rename', () => {
42
+ const entries = parsePorcelainEntries('C old.txt -> copy.txt\n');
43
+ expect(entries).toEqual([{ code: 'C ', path: 'copy.txt', oldPath: 'old.txt' }]);
44
+ });
45
+
46
+ test('parsePorcelainEntries: a NEW path containing a space is quoted by git and must be unquoted', () => {
47
+ // Real `git status --porcelain` output for `git mv new.txt "renamed with space.txt"`.
48
+ const entries = parsePorcelainEntries('R new.txt -> "renamed with space.txt"\n');
49
+ expect(entries).toEqual([{ code: 'R ', path: 'renamed with space.txt', oldPath: 'new.txt' }]);
50
+ });
51
+
52
+ test('parsePorcelainEntries: an OLD path containing a space is quoted independently of the new path', () => {
53
+ const entries = parsePorcelainEntries('R "old with space.txt" -> new.txt\n');
54
+ expect(entries).toEqual([{ code: 'R ', path: 'new.txt', oldPath: 'old with space.txt' }]);
55
+ });
56
+
57
+ test('parsePorcelainEntries: a non-ASCII path is quoted with per-byte octal escapes and must decode back to UTF-8', () => {
58
+ // Real recorded `git status --porcelain` output for a rename onto
59
+ // "résumé file.txt" — git's default core.quotepath escapes each raw UTF-8
60
+ // byte >= 0x80 as \NNN octal: 'é' is UTF-8 bytes 0xC3 0xA9 -> \303\251.
61
+ const line = 'R new.txt -> "r\\303\\251sum\\303\\251 file.txt"\n';
62
+ const entries = parsePorcelainEntries(line);
63
+ expect(entries).toEqual([{ code: 'R ', path: 'résumé file.txt', oldPath: 'new.txt' }]);
64
+ });
65
+
66
+ test('parsePorcelainEntries: non-rename entries are unaffected (no oldPath, path as before)', () => {
67
+ const entries = parsePorcelainEntries(' M src/foo.js\n?? untracked.txt\n');
68
+ expect(entries).toEqual([
69
+ { code: ' M', path: 'src/foo.js' },
70
+ { code: '??', path: 'untracked.txt' },
71
+ ]);
72
+ });
73
+
74
+ test('parsePorcelainEntries: a quoted plain (non-rename) path is also unquoted, not left with literal quotes', () => {
75
+ const entries = parsePorcelainEntries('?? "plain space.txt"\n');
76
+ expect(entries).toEqual([{ code: '??', path: 'plain space.txt' }]);
77
+ });
78
+
79
+ // --- The exact 1187 shape: committed rename must not be flagged reverted ---
80
+
81
+ test('evaluateSharedTreeGuard: a staged rename that is committed during the run is NOT reported reverted (real 1187 case)', () => {
82
+ const oldPath = 'scripts/__tests__/manual-chapter-links.test.cjs';
83
+ const newPath = 'web/manual/__tests__/chapter-links.test.cjs';
84
+ const dirtyBefore = parsePorcelainEntries(`R ${oldPath} -> ${newPath}\n`);
85
+ // git diff --name-only <headBefore>..<headAfter> reports only the NEW path
86
+ // for a committed rename (verified against real git 2.43 behavior).
87
+ const result = evaluateSharedTreeGuard({
88
+ stashBefore: [],
89
+ stashAfter: [],
90
+ dirtyBefore,
91
+ dirtyAfter: [], // fully committed, nothing left dirty
92
+ pathsCommittedDuringRun: [newPath],
93
+ });
94
+ expect(result.reverted).toEqual([]);
95
+ });
96
+
97
+ test('evaluateSharedTreeGuard: a batch of committed renames (the real 45-path shape) are all cleared, none reverted', () => {
98
+ const pairs = [
99
+ ['scripts/__tests__/manual-chapter-links.test.cjs', 'web/manual/__tests__/chapter-links.test.cjs'],
100
+ ['scripts/foo-old.js', 'src/foo-new.js'],
101
+ ['docs/old-name.md', 'docs/new-name.md'],
102
+ ];
103
+ const porcelain = pairs.map(([o, n]) => `R ${o} -> ${n}`).join('\n') + '\n';
104
+ const dirtyBefore = parsePorcelainEntries(porcelain);
105
+ const result = evaluateSharedTreeGuard({
106
+ stashBefore: [],
107
+ stashAfter: [],
108
+ dirtyBefore,
109
+ dirtyAfter: [],
110
+ pathsCommittedDuringRun: pairs.map(([, n]) => n),
111
+ });
112
+ expect(result.reverted).toEqual([]);
113
+ });
114
+
115
+ // --- The guard's real purpose survives: an UNDONE rename still parks ---
116
+
117
+ test('evaluateSharedTreeGuard: a staged rename that is UNDONE (old path restored, new path gone, nothing committed) IS still reported reverted', () => {
118
+ // This test must FAIL if the fix is implemented as "blanket skip rename
119
+ // entries" (dropping every rename-coded entry from consideration would
120
+ // make `reverted` come back empty here, exactly like the committed case
121
+ // above — but here nothing was committed to explain the disappearance).
122
+ const dirtyBefore = parsePorcelainEntries('R old.txt -> new.txt\n');
123
+ const result = evaluateSharedTreeGuard({
124
+ stashBefore: [],
125
+ stashAfter: [],
126
+ dirtyBefore,
127
+ dirtyAfter: [], // clean again — reverted to HEAD, old.txt restored, new.txt gone
128
+ pathsCommittedDuringRun: [], // nothing committed
129
+ });
130
+ expect(result.reverted).toEqual(['new.txt']);
131
+ });
132
+
133
+ // --- The two already-fixed false-positive classes stay fixed (pinned here
134
+ // too, using parsePorcelainEntries as the real entry point rather than
135
+ // hand-built fixtures, so a future change to the parser can't silently
136
+ // regress them without failing a test right next to the rename fix). ---
137
+
138
+ test('evaluateSharedTreeGuard: nowIgnored still works for a real parsePorcelainEntries untracked baseline entry', () => {
139
+ const dirtyBefore = parsePorcelainEntries('?? session-manager-operations/logs/errors-2026-09-12.jsonl\n');
140
+ const result = evaluateSharedTreeGuard({
141
+ stashBefore: [],
142
+ stashAfter: [],
143
+ dirtyBefore,
144
+ dirtyAfter: [],
145
+ pathsCommittedDuringRun: ['.gitignore'],
146
+ existsAfter: ['session-manager-operations/logs/errors-2026-09-12.jsonl'],
147
+ });
148
+ expect(result.reverted).toEqual([]);
149
+ expect(result.nowIgnored).toEqual(['session-manager-operations/logs/errors-2026-09-12.jsonl']);
150
+ });
151
+
152
+ test('evaluateSharedTreeGuard: a genuine deletion of an untracked path (from a real parsePorcelainEntries entry) is still reverted', () => {
153
+ const dirtyBefore = parsePorcelainEntries('?? data/scratch.txt\n');
154
+ const result = evaluateSharedTreeGuard({
155
+ stashBefore: [],
156
+ stashAfter: [],
157
+ dirtyBefore,
158
+ dirtyAfter: [],
159
+ pathsCommittedDuringRun: [],
160
+ existsAfter: [],
161
+ });
162
+ expect(result.reverted).toEqual(['data/scratch.txt']);
163
+ expect(result.nowIgnored).toEqual([]);
164
+ });
@@ -148,6 +148,48 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
148
148
  assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
149
149
  });
150
150
 
151
+ test('reapDeadRunningJobs: a pidless row past grace with a landedCommit already recorded is routed to needs_review, not failed, naming the commit sha (PRD 1173)', async () => {
152
+ const projectCwd = path.join(tmpHome, 'c3-project-pidless-with-landed-commit');
153
+ fs.mkdirSync(projectCwd, { recursive: true });
154
+ registerActiveProject(projectCwd);
155
+
156
+ const staleStartedAt = new Date(Date.now() - PIDLESS_SPAWN_GRACE_MS - 60_000).toISOString();
157
+ const landedSha = 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f';
158
+ const queuePath = writeProjectQueue(projectCwd, [
159
+ {
160
+ slug: 'shipped-but-pidless',
161
+ status: 'running',
162
+ cwd: projectCwd,
163
+ runId: 'run-shipped-but-pidless',
164
+ startedAt: staleStartedAt,
165
+ landedCommit: landedSha,
166
+ // no runtime key at all — the spawn never got far enough to record one
167
+ },
168
+ ]);
169
+ // Empty run dir, same shape as the sibling 'failed' test below — the
170
+ // landedCommit evidence must be what changes the outcome, not the log.
171
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-shipped-but-pidless'), { recursive: true });
172
+
173
+ await reapDeadRunningJobs();
174
+
175
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
176
+ const row = jobs.find((j) => j.slug === 'shipped-but-pidless');
177
+ assert.equal(row.status, 'needs_review', 'a row with proof of landed work must never be transitioned to failed by the pidless branch');
178
+ assert.equal(row.landedCommit, landedSha, 'the pre-existing landedCommit must be preserved on the row');
179
+ assert.match(row.error, new RegExp(landedSha), 'the reason must name the recorded commit sha');
180
+ assert.equal(row.verifierVerdict, 'pidless_reap_with_landed_commit');
181
+
182
+ // Scan the whole audit log for THIS slug's event rather than slicing by a
183
+ // byte offset from statSync — a prior test's audit entries can contain
184
+ // multi-byte characters (e.g. an em dash in a reaper reason string), and
185
+ // slicing a utf8-decoded JS string (UTF-16 code units) at a byte offset
186
+ // then silently corrupts the boundary. The slug is unique to this test.
187
+ const auditLines = fs.readFileSync(AUDIT_LOG_PATH, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
188
+ const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'shipped-but-pidless');
189
+ assert.ok(pidlessEvent, 'reaping this row must still leave an audit trace');
190
+ assert.equal(pidlessEvent.landedCommit, landedSha, 'the audit event must carry the landed-commit evidence so the decision is reconstructable from the audit log alone');
191
+ });
192
+
151
193
  test('reapDeadRunningJobs clears runId on a pidless reap whose run dir holds only a sibling slug\'s files', async () => {
152
194
  const projectCwd = path.join(tmpHome, 'c2-project-batch-sibling');
153
195
  fs.mkdirSync(projectCwd, { recursive: true });
@@ -573,3 +615,121 @@ test('reapDeadRunningJobs: a non-git cwd skips the integration check entirely, p
573
615
  assert.equal(row.status, 'completed', 'a non-git cwd must keep today\'s behaviour — the check is meaningless there');
574
616
  assert.equal(row.verifierVerdict, undefined);
575
617
  });
618
+
619
+ // ---------- Reaper landed-commit evidence gate (job 1192 incident) ----------
620
+ //
621
+ // job 1192 shipped a real 3-file commit (7bf5fa3f...) and was still
622
+ // stamped 'failed' by reapDeadRunningJobs because a `landedCommit` field
623
+ // being present was never actually verified against the repo. These cover
624
+ // the dead-pid branch specifically (runtime.pid recorded, process
625
+ // confirmed dead) rather than the pidless branch: a pidless row with a
626
+ // non-empty landedCommit is already fully owned by PRD 1173's
627
+ // resolvePidlessFailureOverride (routes to needs_review, tested above) —
628
+ // this PRD's gate exists for the shape 1173 doesn't cover, where the
629
+ // reaper would otherwise fall straight through to 'failed' with no
630
+ // evidence check of any kind.
631
+
632
+ test('reapDeadRunningJobs: a dead-pid reap with a git-resolvable landedCommit transitions to completed, naming the reap cause and the evidence, source reapDeadRunningJobs:landed', async () => {
633
+ const projectCwd = path.join(tmpHome, 'n-project-landed-commit-evidence');
634
+ initRepo(projectCwd);
635
+ registerActiveProject(projectCwd);
636
+
637
+ fs.writeFileSync(path.join(projectCwd, 'shipped.txt'), 'done\n', 'utf8');
638
+ git(['add', '-A'], projectCwd);
639
+ git(['commit', '-q', '-m', 'ship the PRD work'], projectCwd);
640
+ const landedSha = git(['rev-parse', 'HEAD'], projectCwd).trim();
641
+
642
+ const runId = 'run-landed-commit-evidence';
643
+ const queuePath = writeProjectQueue(projectCwd, [
644
+ {
645
+ slug: 'landed-but-reaped',
646
+ status: 'running',
647
+ cwd: projectCwd,
648
+ runId,
649
+ runtime: { pid: 999999 }, // guaranteed-dead pid
650
+ landedCommit: landedSha,
651
+ },
652
+ ]);
653
+ // Empty run dir → classifyRunOutcome finds no result event → 'no_result'
654
+ // → without this PRD's gate this would stamp 'failed' despite the real
655
+ // commit already sitting on HEAD.
656
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
657
+
658
+ bustCwdCache();
659
+ await reapDeadRunningJobs();
660
+
661
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
662
+ const row = jobs.find((j) => j.slug === 'landed-but-reaped');
663
+ assert.equal(row.status, 'completed', 'a resolvable landedCommit must promote the reap to completed, not failed');
664
+ assert.equal(row.landedCommit, landedSha);
665
+ assert.equal(row.exitCode, 0);
666
+ assert.equal(row.error, null);
667
+ const lastTransition = row.statusHistory[row.statusHistory.length - 1];
668
+ assert.equal(lastTransition.source, 'reapDeadRunningJobs:landed');
669
+ assert.match(lastTransition.reason, /process gone/, 'the reap cause must still be named');
670
+ assert.match(lastTransition.reason, new RegExp(landedSha), 'the evidence sha must be named');
671
+ assert.match(lastTransition.reason, /resolves/);
672
+ });
673
+
674
+ test('reapDeadRunningJobs: a dead-pid reap with an unresolvable (stale) landedCommit keeps today\'s behaviour exactly — failed, same reason shape, same source', async () => {
675
+ const projectCwd = path.join(tmpHome, 'o-project-stale-landed-commit');
676
+ initRepo(projectCwd);
677
+ registerActiveProject(projectCwd);
678
+
679
+ const staleSha = '0123456789abcdef0123456789abcdef01234567'; // well-formed sha, never committed anywhere
680
+ const runId = 'run-stale-landed-commit';
681
+ const queuePath = writeProjectQueue(projectCwd, [
682
+ {
683
+ slug: 'stale-landed-commit',
684
+ status: 'running',
685
+ cwd: projectCwd,
686
+ runId,
687
+ runtime: { pid: 999999 },
688
+ landedCommit: staleSha,
689
+ },
690
+ ]);
691
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
692
+
693
+ bustCwdCache();
694
+ await reapDeadRunningJobs();
695
+
696
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
697
+ const row = jobs.find((j) => j.slug === 'stale-landed-commit');
698
+ assert.equal(row.status, 'failed', 'a landedCommit that does not resolve in the repo must never be trusted as evidence');
699
+ assert.equal(row.landedCommit, staleSha, 'the stale field is left on the row untouched, never cleared');
700
+ assert.match(row.error, /reaped: process gone \(outcome=no_result\)/);
701
+ assert.doesNotMatch(row.error, /resolves/);
702
+ const lastTransition = row.statusHistory[row.statusHistory.length - 1];
703
+ assert.equal(lastTransition.source, 'reapDeadRunningJobs', 'no evidence found — source must stay the plain reaper source, not :landed');
704
+ });
705
+
706
+ test('reapDeadRunningJobs: a landedCommit that cannot be git-resolved because the row cwd is not a usable git repo falls back to failed without throwing out of the reaper', async () => {
707
+ const projectCwd = path.join(tmpHome, 'p-project-unresolvable-cwd');
708
+ // Deliberately NOT initRepo(projectCwd) — no `.git` at all, the same
709
+ // "cwd git resolution cannot possibly succeed" shape a deleted/torn-down
710
+ // worktree checkout would produce (git itself errors identically for
711
+ // "not a git repository" and "no such directory" — both must be caught
712
+ // and treated as no-evidence, never thrown out of the reaper).
713
+ fs.mkdirSync(projectCwd, { recursive: true });
714
+ registerActiveProject(projectCwd);
715
+
716
+ const runId = 'run-unresolvable-cwd';
717
+ const queuePath = writeProjectQueue(projectCwd, [
718
+ {
719
+ slug: 'unresolvable-cwd-job',
720
+ status: 'running',
721
+ cwd: projectCwd,
722
+ runId,
723
+ runtime: { pid: 999999 },
724
+ landedCommit: '89abcdef89abcdef89abcdef89abcdef89abcdef',
725
+ },
726
+ ]);
727
+ fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId), { recursive: true });
728
+
729
+ bustCwdCache();
730
+ await assert.doesNotReject(() => reapDeadRunningJobs(), 'a bounded git spawn failure/timeout must never escape the reaper as an unhandled rejection');
731
+
732
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
733
+ const row = jobs.find((j) => j.slug === 'unresolvable-cwd-job');
734
+ assert.equal(row.status, 'failed', 'an unresolvable cwd must fall back to failed, exactly like no evidence at all');
735
+ });