claude-code-session-manager 0.85.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 (147) hide show
  1. package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
  2. package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
  3. package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
  4. package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
  5. package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
  6. package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
  7. package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
  8. package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
  9. package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
  10. package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
  11. package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
  12. package/dist/assets/Permissions-BcUC-5y8.js +3 -0
  13. package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
  15. package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
  16. package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
  17. package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
  18. package/dist/assets/Settings-DluB-vN1.js +3 -0
  19. package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
  20. package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
  21. package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
  22. package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
  23. package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
  24. package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
  25. package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
  26. package/dist/assets/{index-DhvuQL4C.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 +15 -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__/health-starve-escalation.test.cjs +94 -0
  40. package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
  41. package/src/main/__tests__/machineProfile.test.cjs +19 -1
  42. package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
  43. package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
  44. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
  45. package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
  46. package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
  47. package/src/main/__tests__/prdLocations.test.cjs +100 -2
  48. package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
  49. package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
  50. package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
  51. package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
  52. package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
  53. package/src/main/__tests__/queueHistory.test.cjs +63 -0
  54. package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
  55. package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
  56. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
  57. package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
  58. package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
  59. package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
  60. package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
  61. package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
  62. package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
  63. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
  64. package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
  65. package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
  66. package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
  67. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
  68. package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
  69. package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
  70. package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
  71. package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
  72. package/src/main/__tests__/telemetryClient.test.cjs +75 -5
  73. package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
  74. package/src/main/chatRunner.cjs +8 -5
  75. package/src/main/health.cjs +76 -3
  76. package/src/main/historyAggregator.cjs +5 -0
  77. package/src/main/index.cjs +95 -44
  78. package/src/main/ipcSchemas.cjs +47 -0
  79. package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
  80. package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
  81. package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
  82. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
  83. package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
  84. package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
  85. package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
  86. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
  87. package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
  88. package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
  89. package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
  90. package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
  91. package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
  92. package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
  93. package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
  94. package/src/main/lib/agentModelResolve.cjs +65 -27
  95. package/src/main/lib/bootSelfHeal.cjs +88 -0
  96. package/src/main/lib/delegationReadiness.cjs +290 -225
  97. package/src/main/lib/effectiveModelInfo.cjs +333 -0
  98. package/src/main/lib/ephemeralCwd.cjs +1 -1
  99. package/src/main/lib/epicMint.cjs +3 -3
  100. package/src/main/lib/gitWorktree.cjs +42 -12
  101. package/src/main/lib/guardShims.cjs +156 -0
  102. package/src/main/lib/jobDirtFilter.cjs +7 -2
  103. package/src/main/lib/launchFailure.cjs +2 -1
  104. package/src/main/lib/loadGate.cjs +23 -1
  105. package/src/main/lib/machineProfile.cjs +15 -0
  106. package/src/main/lib/mcpToolCatalog.cjs +4 -1
  107. package/src/main/lib/needsReviewLedger.cjs +205 -0
  108. package/src/main/lib/opsErrorLog.cjs +1 -1
  109. package/src/main/lib/opsOwnership.cjs +1 -1
  110. package/src/main/lib/prdCreate.cjs +56 -1
  111. package/src/main/lib/prdDisposition.cjs +199 -0
  112. package/src/main/lib/prdFrontmatter.cjs +8 -2
  113. package/src/main/lib/prdLocations.cjs +167 -45
  114. package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
  115. package/src/main/lib/projectPageSummarySchema.cjs +1 -1
  116. package/src/main/lib/projectRootResolve.cjs +1 -1
  117. package/src/main/lib/queueHistory.cjs +19 -1
  118. package/src/main/lib/queueStore.cjs +6 -1
  119. package/src/main/lib/reaperHelpers.cjs +181 -15
  120. package/src/main/lib/scheduleJobSchema.cjs +8 -0
  121. package/src/main/lib/scheduleJobTransitions.cjs +33 -0
  122. package/src/main/lib/schedulerBatch.cjs +12 -1
  123. package/src/main/lib/schedulerConfig.cjs +37 -0
  124. package/src/main/lib/telemetryBoot.cjs +11 -8
  125. package/src/main/lib/telemetryClient.cjs +44 -2
  126. package/src/main/lib/telemetrySettings.cjs +20 -3
  127. package/src/main/lib/usageCircuit.cjs +159 -0
  128. package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
  129. package/src/main/pty.cjs +9 -0
  130. package/src/main/scheduler/prdParser.cjs +13 -0
  131. package/src/main/scheduler.cjs +1869 -193
  132. package/src/main/templates/PRD_AUTHORING.md +50 -0
  133. package/src/main/templates/project-pages-catalog.json +1 -1
  134. package/src/main/usage.cjs +21 -3
  135. package/src/preload/api.d.ts +92 -1
  136. package/src/preload/index.cjs +10 -0
  137. package/web/README.md +41 -0
  138. package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
  139. package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
  140. package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
  141. package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
  142. package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
  143. package/dist/assets/Permissions-DKoNVgzj.js +0 -3
  144. package/dist/assets/Scheduler-CbES7MC8.js +0 -14
  145. package/dist/assets/Settings-BX3FElXk.js +0 -3
  146. package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
  147. /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * scheduler-guard-verdict-autoresolve.test.cjs — needs_review ladder gap
3
+ * (2026-09-12, PRD 1181 shape).
4
+ *
5
+ * Auto-fix investigations only ever launch for FAILING runs — a job that
6
+ * exits 0 and gets parked by a post-run GUARD verdict (commit-guard
7
+ * 'silent_no_op', shared-tree-guard 'shared_tree_reverted') never has
8
+ * job.autoFixAttempted set, so isExhaustedAutoFix(job) is false forever and
9
+ * the row is invisible to the existing needs_review auto-resolve ladder —
10
+ * it sits in needs_review permanently, blocking every dependsOn row behind
11
+ * it, until a human clears it by hand. PRD 1181 proved it: exit 0, commit
12
+ * aff5607 landed, parked on a guard verdict, needed a human.
13
+ *
14
+ * This file covers the widened entry gate (isGuardParkedWithoutAutoFix /
15
+ * isEligibleForNeedsReviewAutoResolve) that admits these rows to the SAME
16
+ * ladder (selectExhaustedNeedsReviewTargets / applyNeedsReviewAutoResolve)
17
+ * the exhausted-auto-fix path already uses, keyed on landedCommit +
18
+ * looksDone evidence instead of a spent auto-fix attempt — and the closed
19
+ * set of verdicts eligible for this (GUARD_VERDICT_EVIDENCE_ELIGIBLE)
20
+ * excludes 'worktree_integration_failed', whose damage IS a commit (one
21
+ * stranded on an unmerged branch), so a landedCommit there is not evidence
22
+ * against the verdict.
23
+ *
24
+ * scheduler-needs-review-autoresolve.test.cjs's own suite covers the
25
+ * pre-existing exhausted-auto-fix ladder and must keep passing UNCHANGED —
26
+ * this file only adds new coverage for the second door into the same ladder.
27
+ *
28
+ * HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
29
+ * reason as scheduler-needs-review-autoresolve.test.cjs (appendAuditEvent
30
+ * writes under $HOME/.claude/session-manager/audit-log.jsonl).
31
+ *
32
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const assert = require('node:assert/strict');
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+
42
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-verdict-autoresolve-test-'));
43
+ process.env.HOME = tmpHome;
44
+
45
+ const { execFileSync } = require('node:child_process');
46
+
47
+ const {
48
+ selectExhaustedNeedsReviewTargets,
49
+ applyNeedsReviewAutoResolve,
50
+ NEEDS_REVIEW_RESOLVE_CAP,
51
+ GUARD_VERDICT_EVIDENCE_ELIGIBLE,
52
+ isGuardParkedWithoutAutoFix,
53
+ isEligibleForNeedsReviewAutoResolve,
54
+ isExhaustedAutoFix,
55
+ reverifyNeedsReview,
56
+ } = require('../scheduler.cjs');
57
+
58
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
59
+ const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
60
+ const { bustCwdCache } = require('../lib/queueStore.cjs');
61
+
62
+ const MIN_MS = 60_000;
63
+ const THRESHOLD_MS = 30 * MIN_MS;
64
+
65
+ function guardParkedJob(overrides = {}) {
66
+ return {
67
+ slug: '1181-guard-parked-row',
68
+ cwd: '/home/user/project',
69
+ status: 'needs_review',
70
+ exitCode: 0,
71
+ verifierVerdict: 'silent_no_op',
72
+ landedCommit: 'aff5607abc123',
73
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * MIN_MS).toISOString() }],
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ // --- enumeration: which guard verdicts are eligible ---
79
+
80
+ test('GUARD_VERDICT_EVIDENCE_ELIGIBLE contains exactly the benign guard verdicts, never worktree_integration_failed', () => {
81
+ assert.ok(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('silent_no_op'));
82
+ assert.ok(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('shared_tree_reverted'));
83
+ assert.equal(GUARD_VERDICT_EVIDENCE_ELIGIBLE.has('worktree_integration_failed'), false);
84
+ });
85
+
86
+ // --- isGuardParkedWithoutAutoFix / isEligibleForNeedsReviewAutoResolve ---
87
+
88
+ test('a guard-verdict park with no auto-fix history is guard-parked-without-autofix', () => {
89
+ const job = guardParkedJob();
90
+ assert.equal(isGuardParkedWithoutAutoFix(job), true);
91
+ assert.equal(isExhaustedAutoFix(job), false, 'never went through auto-fix — must not double-count as exhausted');
92
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), true);
93
+ });
94
+
95
+ test('a row that DID get an auto-fix investigation is left to the exhausted-auto-fix door, not this one', () => {
96
+ const job = guardParkedJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 1 });
97
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
98
+ assert.equal(isExhaustedAutoFix(job), true);
99
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), true, 'still eligible overall, via the OTHER door');
100
+ });
101
+
102
+ test('(c) worktree_integration_failed is never guard-parked-without-autofix, whatever evidence it carries', () => {
103
+ const job = guardParkedJob({ verifierVerdict: 'worktree_integration_failed' });
104
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
105
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), false);
106
+ });
107
+
108
+ test('a needs_review row with an unrecognized verifierVerdict and no auto-fix history is not eligible', () => {
109
+ const job = guardParkedJob({ verifierVerdict: 'transcript_errors' });
110
+ assert.equal(isGuardParkedWithoutAutoFix(job), false);
111
+ assert.equal(isEligibleForNeedsReviewAutoResolve(job), false);
112
+ });
113
+
114
+ // --- selectExhaustedNeedsReviewTargets: the widened selector ---
115
+
116
+ test('a fresh guard-parked row (under threshold) is not selected', () => {
117
+ const job = guardParkedJob({
118
+ statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
119
+ });
120
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
121
+ });
122
+
123
+ test('a guard-parked row past threshold, under the cap, is selected', () => {
124
+ const job = guardParkedJob();
125
+ const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
126
+ assert.equal(found.length, 1);
127
+ assert.equal(found[0].slug, '1181-guard-parked-row');
128
+ });
129
+
130
+ test('(c) worktree_integration_failed is never selected, even past threshold with a landedCommit', () => {
131
+ const job = guardParkedJob({ verifierVerdict: 'worktree_integration_failed' });
132
+ assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
133
+ });
134
+
135
+ // --- applyNeedsReviewAutoResolve: the three-branch policy, entered via the guard door ---
136
+
137
+ test('(a) the 1181 shape — exit 0 + guard verdict + landedCommit + looksDone — auto-completes', () => {
138
+ const job = guardParkedJob({
139
+ looksDone: { commits: ['aff5607'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
140
+ });
141
+ const outcome = applyNeedsReviewAutoResolve(job);
142
+ assert.equal(outcome, 'completed');
143
+ assert.equal(job.status, 'completed');
144
+ assert.match(job.statusHistory.at(-1).reason, /silent_no_op/, 'the reason must name the evidence, not just "auto-resolved"');
145
+ });
146
+
147
+ test('(b) the same shape WITHOUT looksDone does not auto-complete on the first pass — bounded requeue instead', () => {
148
+ const job = guardParkedJob({ exhaustedResolveAttempts: 0 });
149
+ assert.equal(job.looksDone, undefined);
150
+ const outcome = applyNeedsReviewAutoResolve(job);
151
+ assert.equal(outcome, 'requeued');
152
+ assert.equal(job.status, 'pending');
153
+ assert.equal(job.exhaustedResolveAttempts, 1);
154
+ assert.match(job.statusHistory.at(-1).reason, /silent_no_op/);
155
+ });
156
+
157
+ test('a guard-parked row with no evidence, cap spent, auto-skips with the guard verdict named in job.error', () => {
158
+ const job = guardParkedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
159
+ const outcome = applyNeedsReviewAutoResolve(job);
160
+ assert.equal(outcome, 'skipped');
161
+ assert.equal(job.status, 'skipped');
162
+ assert.equal(job.needsReviewAutoResolvedSkip, true);
163
+ assert.match(job.error, /silent_no_op/, 'a human reading the queue must see WHY, not a generic auto-resolved label');
164
+ assert.doesNotMatch(job.error, /exhausted auto-fix path/, 'this row never went through auto-fix — must not claim it did');
165
+ });
166
+
167
+ test('(c) worktree_integration_failed never auto-completes on landed-commit + looksDone evidence', () => {
168
+ const job = guardParkedJob({
169
+ verifierVerdict: 'worktree_integration_failed',
170
+ looksDone: { commits: ['aff5607'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
171
+ });
172
+ const outcome = applyNeedsReviewAutoResolve(job);
173
+ assert.equal(outcome, null, 'this verdict has no door into this ladder at all');
174
+ assert.equal(job.status, 'needs_review', 'must stay parked — its own mechanical-recovery path owns this verdict, not this ladder');
175
+ });
176
+
177
+ test('the pre-existing exhausted-auto-fix ladder is unaffected: same branch, same cap, same audit shape', () => {
178
+ const job = {
179
+ slug: 'exhausted-row',
180
+ cwd: '/home/user/project',
181
+ status: 'needs_review',
182
+ autoFixAttempted: true,
183
+ autoFixOutcome: 'no-plan',
184
+ autoFixRetries: 1,
185
+ exhaustedResolveAttempts: 1,
186
+ looksDone: { commits: ['abc1234'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
187
+ };
188
+ const outcome = applyNeedsReviewAutoResolve(job);
189
+ assert.equal(outcome, 'completed');
190
+ assert.match(job.statusHistory.at(-1).reason, /verifier annotation shows work landed/);
191
+ });
192
+
193
+ // --- (d) chain drain: a downstream dependsOn row becomes eligible once the guard-parked blocker resolves ---
194
+
195
+ test('(d) a dependsOn chain drains once a guard-parked blocker (no auto-fix, no evidence, cap spent) auto-skips', () => {
196
+ const now = Date.now();
197
+ const rowA = { slug: 'a-first', cwd: '/home/user/project', status: 'completed' };
198
+ const rowB = guardParkedJob({
199
+ slug: 'b-middle',
200
+ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
201
+ dependsOn: ['a-first'],
202
+ });
203
+ const rowC = { slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'] };
204
+ const jobs = [rowA, rowB, rowC];
205
+
206
+ const before = pickForProject(jobs, new Set(), 5);
207
+ assert.equal(before.batch.length, 0, 'C must not be eligible while B still sits in needs_review');
208
+
209
+ const targets = selectExhaustedNeedsReviewTargets(jobs, now, THRESHOLD_MS);
210
+ assert.equal(targets.length, 1);
211
+ assert.equal(targets[0].slug, 'b-middle');
212
+ const outcome = applyNeedsReviewAutoResolve(rowB);
213
+ assert.equal(outcome, 'skipped');
214
+
215
+ const after = pickForProject(jobs, new Set(), 5);
216
+ assert.ok(after.batch.some((j) => j.slug === 'c-last'), 'C must become eligible once B is auto-skipped');
217
+ });
218
+
219
+ // --- end-to-end: reverifyNeedsReview computes looksDone for a guard-parked row ---
220
+
221
+ function git(args, cwd) {
222
+ return execFileSync('git', args, { cwd, encoding: 'utf8' });
223
+ }
224
+
225
+ function initRepo(dir) {
226
+ fs.mkdirSync(dir, { recursive: true });
227
+ git(['init', '-q'], dir);
228
+ git(['config', 'user.email', 'test@example.com'], dir);
229
+ git(['config', 'user.name', 'Test'], dir);
230
+ fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
231
+ git(['add', '-A'], dir);
232
+ git(['commit', '-q', '-m', 'initial'], dir);
233
+ }
234
+
235
+ function commitFile(dir, relPath, content, message) {
236
+ const abs = path.join(dir, relPath);
237
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
238
+ fs.writeFileSync(abs, content);
239
+ git(['add', relPath], dir);
240
+ git(['commit', '-q', '-m', message], dir);
241
+ }
242
+
243
+ function registerActiveProject(cwd, slug) {
244
+ const slugDir = path.join(tmpHome, '.claude', 'projects', slug);
245
+ fs.mkdirSync(slugDir, { recursive: true });
246
+ fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
247
+ bustCwdCache();
248
+ }
249
+
250
+ function writeProjectQueue(cwd, jobs) {
251
+ const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
252
+ fs.mkdirSync(stateDir, { recursive: true });
253
+ fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
254
+ return path.join(stateDir, 'queue.json');
255
+ }
256
+
257
+ function writePrd(cwd, slug, body) {
258
+ const dir = resolvePrdWriteDir(cwd);
259
+ fs.mkdirSync(dir, { recursive: true });
260
+ const text = `---\ntitle: Test PRD\ncwd: ${cwd}\nestimateMinutes: 30\n---\n${body}\n`;
261
+ fs.writeFileSync(path.join(dir, `${slug}.md`), text);
262
+ }
263
+
264
+ async function wait(ms) {
265
+ await new Promise((r) => setTimeout(r, ms));
266
+ }
267
+
268
+ test('reverifyNeedsReview computes looksDone for a guard-parked (silent_no_op) row with no auto-fix history', async () => {
269
+ const projectCwd = path.join(tmpHome, 'proj-guard-parked');
270
+ initRepo(projectCwd);
271
+ registerActiveProject(projectCwd, 'proj-guard-parked-slug');
272
+ writePrd(projectCwd, '1181-guard-parked', [
273
+ '# Implementation notes',
274
+ 'Edit `src/thing.js`.',
275
+ ].join('\n'));
276
+
277
+ const startedAt = new Date().toISOString();
278
+ const queuePath = writeProjectQueue(projectCwd, [
279
+ {
280
+ slug: '1181-guard-parked',
281
+ status: 'needs_review',
282
+ cwd: projectCwd,
283
+ runId: 'run-1181',
284
+ startedAt,
285
+ finishedAt: new Date().toISOString(),
286
+ exitCode: 0,
287
+ verifierVerdict: 'silent_no_op',
288
+ landedCommit: 'deadbeef',
289
+ error: 'commit-guard: made no commit on an already-clean tree',
290
+ },
291
+ ]);
292
+
293
+ await wait(1100); // git --since has 1s resolution
294
+ // Must be attributable to THIS job (see attributeLandedCommits) — path
295
+ // overlap alone is no longer evidence. The row's own `landedCommit`
296
+ // ('deadbeef') is a stale/unresolvable placeholder, so this falls to the
297
+ // 'slug trailer' rule by naming the job's own slug in the commit message.
298
+ commitFile(projectCwd, 'src/thing.js', 'hello', 'the actual fix for 1181-guard-parked landed');
299
+
300
+ await reverifyNeedsReview();
301
+
302
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
303
+ assert.equal(jobs.length, 1);
304
+ assert.equal(jobs[0].status, 'needs_review', 'looksDone alone never auto-completes — only applyNeedsReviewAutoResolve does');
305
+ assert.ok(jobs[0].looksDone, 'expected a looksDone annotation computed for this guard-parked row');
306
+ assert.equal(jobs[0].looksDone.commits.length, 1);
307
+ assert.equal(jobs[0].looksDone.rule, 'slug trailer');
308
+ });
309
+
310
+ test('reverifyNeedsReview never computes looksDone for a worktree_integration_failed row', async () => {
311
+ const projectCwd = path.join(tmpHome, 'proj-stranded-branch');
312
+ initRepo(projectCwd);
313
+ registerActiveProject(projectCwd, 'proj-stranded-branch-slug');
314
+ writePrd(projectCwd, '1181-stranded', [
315
+ '# Implementation notes',
316
+ 'Edit `src/thing.js`.',
317
+ ].join('\n'));
318
+
319
+ const startedAt = new Date().toISOString();
320
+ const queuePath = writeProjectQueue(projectCwd, [
321
+ {
322
+ slug: '1181-stranded',
323
+ status: 'needs_review',
324
+ cwd: projectCwd,
325
+ runId: 'run-stranded',
326
+ startedAt,
327
+ finishedAt: new Date().toISOString(),
328
+ exitCode: 0,
329
+ verifierVerdict: 'worktree_integration_failed',
330
+ landedCommit: 'deadbeef',
331
+ error: 'worktree branch integration failed',
332
+ },
333
+ ]);
334
+
335
+ await wait(1100);
336
+ commitFile(projectCwd, 'src/thing.js', 'hello', 'unrelated later commit');
337
+
338
+ await reverifyNeedsReview();
339
+
340
+ const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
341
+ assert.equal(jobs.length, 1);
342
+ assert.equal(jobs[0].looksDone, undefined, 'a stranded-branch verdict must never be treated as evidence-gatherable');
343
+ assert.equal(jobs[0].status, 'needs_review');
344
+ });
@@ -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
+ });