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.
- package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/__tests__/telemetryClient.test.cjs +75 -5
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +76 -3
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +37 -0
- package/src/main/lib/telemetryBoot.cjs +11 -8
- package/src/main/lib/telemetryClient.cjs +44 -2
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1869 -193
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
- package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
- package/dist/assets/Permissions-DKoNVgzj.js +0 -3
- package/dist/assets/Scheduler-CbES7MC8.js +0 -14
- package/dist/assets/Settings-BX3FElXk.js +0 -3
- package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-needs-review-autoresolve.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* Covers the bounded automatic terminal decision for an EXHAUSTED
|
|
5
|
+
* needs_review row (isExhaustedAutoFix === true): selectExhaustedNeedsReviewTargets
|
|
6
|
+
* (the pure selector), applyNeedsReviewAutoResolve (the three-branch policy
|
|
7
|
+
* applier, extracted from the 10-minute interval body so it's testable
|
|
8
|
+
* without queue.json IO), needsReviewAutoResolveDisabled (its kill switch),
|
|
9
|
+
* and the dependsOn carve-out in schedulerBatch.cjs's findBlockingDep that
|
|
10
|
+
* lets a chain actually drain once this pass auto-skips a row.
|
|
11
|
+
*
|
|
12
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
|
|
13
|
+
* reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
|
|
14
|
+
* under $HOME/.claude/session-manager/audit-log.jsonl).
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const assert = require('node:assert/strict');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'needs-review-autoresolve-test-'));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
selectExhaustedNeedsReviewTargets,
|
|
31
|
+
applyNeedsReviewAutoResolve,
|
|
32
|
+
NEEDS_REVIEW_RESOLVE_CAP,
|
|
33
|
+
needsReviewAutoResolveDisabled,
|
|
34
|
+
} = require('../scheduler.cjs');
|
|
35
|
+
|
|
36
|
+
const { pickForProject } = require('../lib/schedulerBatch.cjs');
|
|
37
|
+
|
|
38
|
+
const MIN_MS = 60_000;
|
|
39
|
+
const THRESHOLD_MS = 30 * MIN_MS;
|
|
40
|
+
|
|
41
|
+
function exhaustedJob(overrides = {}) {
|
|
42
|
+
return {
|
|
43
|
+
slug: 'exhausted-row',
|
|
44
|
+
cwd: '/home/user/project',
|
|
45
|
+
status: 'needs_review',
|
|
46
|
+
autoFixAttempted: true,
|
|
47
|
+
autoFixOutcome: 'no-plan',
|
|
48
|
+
autoFixRetries: 1, // spent, per isExhaustedAutoFix
|
|
49
|
+
statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 45 * MIN_MS).toISOString() }],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- selectExhaustedNeedsReviewTargets ---
|
|
55
|
+
|
|
56
|
+
test('a fresh exhausted needs_review row (under threshold) is not selected', () => {
|
|
57
|
+
const job = exhaustedJob({
|
|
58
|
+
statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
|
|
59
|
+
});
|
|
60
|
+
assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a non-exhausted needs_review row (still eligible for a real auto-fix investigation) is left alone', () => {
|
|
64
|
+
// autoFixRetries: 0 — isExhaustedAutoFix is false here (an unspent retry
|
|
65
|
+
// remains); this pass must never steal a row selectAutoFixTargets still owns.
|
|
66
|
+
const job = exhaustedJob({ autoFixRetries: 0 });
|
|
67
|
+
assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a non-needs_review status is never selected', () => {
|
|
71
|
+
const job = exhaustedJob({ status: 'failed' });
|
|
72
|
+
assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an exhausted row past threshold, under the cap, is selected', () => {
|
|
76
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: 0 });
|
|
77
|
+
const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
|
|
78
|
+
assert.equal(found.length, 1);
|
|
79
|
+
assert.equal(found[0].slug, 'exhausted-row');
|
|
80
|
+
assert.equal(found[0].attempts, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('an exhausted row AT the cap is still selected — this is the pass that must terminally skip it', () => {
|
|
84
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
|
|
85
|
+
const found = selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS);
|
|
86
|
+
assert.equal(found.length, 1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a row already past the cap (defensive — should never occur once skipped) is excluded', () => {
|
|
90
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP + 1 });
|
|
91
|
+
assert.equal(selectExhaustedNeedsReviewTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('needsReviewAutoResolveDisabled reflects SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE', () => {
|
|
95
|
+
const saved = process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
|
|
96
|
+
try {
|
|
97
|
+
delete process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
|
|
98
|
+
assert.equal(needsReviewAutoResolveDisabled(), false);
|
|
99
|
+
process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE = '1';
|
|
100
|
+
assert.equal(needsReviewAutoResolveDisabled(), true);
|
|
101
|
+
} finally {
|
|
102
|
+
if (saved === undefined) delete process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE;
|
|
103
|
+
else process.env.SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE = saved;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// --- applyNeedsReviewAutoResolve: the three policy branches ---
|
|
108
|
+
|
|
109
|
+
test('branch 1: a looksDone annotation resolves the row to completed', () => {
|
|
110
|
+
const job = exhaustedJob({
|
|
111
|
+
exhaustedResolveAttempts: 1,
|
|
112
|
+
looksDone: { commits: ['abc1234'], paths: ['src/main/scheduler.cjs'], detectedAt: new Date().toISOString() },
|
|
113
|
+
});
|
|
114
|
+
const outcome = applyNeedsReviewAutoResolve(job);
|
|
115
|
+
assert.equal(outcome, 'completed');
|
|
116
|
+
assert.equal(job.status, 'completed');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('branch 2: no looksDone, under the cap, requeues to pending and increments the attempt counter', () => {
|
|
120
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: 0 });
|
|
121
|
+
const outcome = applyNeedsReviewAutoResolve(job);
|
|
122
|
+
assert.equal(outcome, 'requeued');
|
|
123
|
+
assert.equal(job.status, 'pending');
|
|
124
|
+
assert.equal(job.exhaustedResolveAttempts, 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('branch 2 again: a second requeue at attempts=1 also succeeds (cap is 2)', () => {
|
|
128
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: 1 });
|
|
129
|
+
const outcome = applyNeedsReviewAutoResolve(job);
|
|
130
|
+
assert.equal(outcome, 'requeued');
|
|
131
|
+
assert.equal(job.status, 'pending');
|
|
132
|
+
assert.equal(job.exhaustedResolveAttempts, 2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('branch 3: once the cap is spent, the row is auto-skipped with the reason recorded in job.error', () => {
|
|
136
|
+
const job = exhaustedJob({ exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP });
|
|
137
|
+
const outcome = applyNeedsReviewAutoResolve(job);
|
|
138
|
+
assert.equal(outcome, 'skipped');
|
|
139
|
+
assert.equal(job.status, 'skipped');
|
|
140
|
+
assert.ok(typeof job.error === 'string' && job.error.length > 0, 'job.error must name the exhausted auto-fix path');
|
|
141
|
+
assert.match(job.error, /exhausted auto-fix/i);
|
|
142
|
+
assert.equal(job.needsReviewAutoResolvedSkip, true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('applyNeedsReviewAutoResolve race-guards against a row that moved off needs_review', () => {
|
|
146
|
+
const job = exhaustedJob({ status: 'completed', exhaustedResolveAttempts: 0 });
|
|
147
|
+
assert.equal(applyNeedsReviewAutoResolve(job), null);
|
|
148
|
+
assert.equal(job.status, 'completed'); // untouched
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// --- chain drain: the actual point of this PRD ---
|
|
152
|
+
|
|
153
|
+
test('a 3-row dependsOn chain drains: the exhausted middle row auto-skips and the third row becomes eligible', () => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const rowA = {
|
|
156
|
+
slug: 'a-first', cwd: '/home/user/project', status: 'completed',
|
|
157
|
+
};
|
|
158
|
+
const rowB = exhaustedJob({
|
|
159
|
+
slug: 'b-middle',
|
|
160
|
+
cwd: '/home/user/project',
|
|
161
|
+
exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
|
|
162
|
+
dependsOn: ['a-first'],
|
|
163
|
+
});
|
|
164
|
+
const rowC = {
|
|
165
|
+
slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'],
|
|
166
|
+
};
|
|
167
|
+
const jobs = [rowA, rowB, rowC];
|
|
168
|
+
|
|
169
|
+
// Before resolution: C is blocked behind B's live needs_review row.
|
|
170
|
+
const before = pickForProject(jobs, new Set(), 5);
|
|
171
|
+
assert.equal(before.batch.length, 0, 'C must not be eligible while B still sits in needs_review');
|
|
172
|
+
|
|
173
|
+
const targets = selectExhaustedNeedsReviewTargets(jobs, now, THRESHOLD_MS);
|
|
174
|
+
assert.equal(targets.length, 1);
|
|
175
|
+
assert.equal(targets[0].slug, 'b-middle');
|
|
176
|
+
const outcome = applyNeedsReviewAutoResolve(rowB);
|
|
177
|
+
assert.equal(outcome, 'skipped');
|
|
178
|
+
assert.equal(rowB.status, 'skipped');
|
|
179
|
+
|
|
180
|
+
const after = pickForProject(jobs, new Set(), 5);
|
|
181
|
+
assert.ok(after.batch.some((j) => j.slug === 'c-last'), 'C must become eligible once B is auto-skipped');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a generic (non-auto-resolved) skipped dep still blocks forever — the carve-out is narrow', () => {
|
|
185
|
+
const rowB = { slug: 'b-middle', cwd: '/home/user/project', status: 'skipped' }; // no needsReviewAutoResolvedSkip marker
|
|
186
|
+
const rowC = { slug: 'c-last', cwd: '/home/user/project', status: 'pending', dependsOn: ['b-middle'] };
|
|
187
|
+
const { batch } = pickForProject([rowB, rowC], new Set(), 5);
|
|
188
|
+
assert.equal(batch.length, 0, 'a PRD-source-vanished skip must remain a permanent block, unaffected by this carve-out');
|
|
189
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-no-dead-end-status.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* Structural guard for the no-human-intervention guarantee (this PRD): every
|
|
5
|
+
* value in `JOB_STATUSES` (imported from scheduleJobSchema.cjs — the real
|
|
6
|
+
* array, never a hand-written copy) must either be genuinely final
|
|
7
|
+
* (completed/skipped) or have at least one AUTOMATED selector that can move
|
|
8
|
+
* a row parked in it, with no human action required.
|
|
9
|
+
*
|
|
10
|
+
* Each non-final status is asserted by CONSTRUCTING a representative parked
|
|
11
|
+
* row (a stale timestamp + a counter positioned so the row is still
|
|
12
|
+
* selectable) and showing the named selector actually returns it — not by
|
|
13
|
+
* asserting a hand-maintained status -> selector-name string map, which
|
|
14
|
+
* would pass even if the real wiring silently broke. If a future change adds
|
|
15
|
+
* a new status to JOB_STATUSES with no automated exit wired up here, this
|
|
16
|
+
* test fails loudly instead of the row becoming a new, silent dead end.
|
|
17
|
+
*
|
|
18
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
|
|
19
|
+
* reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
|
|
20
|
+
* under $HOME/.claude/session-manager/audit-log.jsonl).
|
|
21
|
+
*
|
|
22
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-no-dead-end-status.test.cjs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const assert = require('node:assert/strict');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'no-dead-end-status-test-'));
|
|
33
|
+
process.env.HOME = tmpHome;
|
|
34
|
+
|
|
35
|
+
const { JOB_STATUSES } = require('../lib/scheduleJobSchema.cjs');
|
|
36
|
+
const {
|
|
37
|
+
selectFailedAutoResetTargets,
|
|
38
|
+
FAILED_AUTORESET_CAP,
|
|
39
|
+
selectExhaustedNeedsReviewTargets,
|
|
40
|
+
NEEDS_REVIEW_RESOLVE_CAP,
|
|
41
|
+
selectQuarantineAutoResolveTargets,
|
|
42
|
+
QUARANTINE_RESOLVE_CAP,
|
|
43
|
+
findStrandedInvestigations,
|
|
44
|
+
INVESTIGATION_MAX_MS,
|
|
45
|
+
} = require('../scheduler.cjs');
|
|
46
|
+
const { selectReapableJobs } = require('../lib/reaperHelpers.cjs');
|
|
47
|
+
const { pickForProject } = require('../lib/schedulerBatch.cjs');
|
|
48
|
+
|
|
49
|
+
const MIN_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
// Every terminal status (no automated exit needed — nothing should ever
|
|
52
|
+
// move a row out of these) and every non-terminal status's proof that a
|
|
53
|
+
// row parked there is reachable by an automated selector, keyed only for
|
|
54
|
+
// this test's own assertions (never treated as authoritative wiring below).
|
|
55
|
+
const FINAL_STATUSES = new Set(['completed', 'skipped']);
|
|
56
|
+
|
|
57
|
+
test('JOB_STATUSES contains exactly the statuses this test accounts for (fails loudly on drift)', () => {
|
|
58
|
+
// Not a hand-maintained selector map — just a manifest of what this file
|
|
59
|
+
// below actually exercises, so an entirely new, unhandled status trips
|
|
60
|
+
// this assertion instead of silently passing the loop below with zero
|
|
61
|
+
// coverage.
|
|
62
|
+
const accountedFor = new Set([
|
|
63
|
+
'pending', 'running', 'investigating', 'completed', 'skipped', 'failed', 'needs_review', 'quarantined',
|
|
64
|
+
]);
|
|
65
|
+
const unaccounted = JOB_STATUSES.filter((s) => !accountedFor.has(s));
|
|
66
|
+
if (unaccounted.length > 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`HALT: JOB_STATUSES has status(es) with no automated-exit test coverage: ${unaccounted.join(', ')} — `
|
|
69
|
+
+ 'add a case to scheduler-no-dead-end-status.test.cjs before shipping this status.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('every terminal status in JOB_STATUSES is genuinely final (completed/skipped only)', () => {
|
|
75
|
+
for (const status of JOB_STATUSES) {
|
|
76
|
+
if (status === 'completed' || status === 'skipped') continue;
|
|
77
|
+
assert.ok(!FINAL_STATUSES.has(status), `${status} must not be treated as final`);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('pending: a fresh pending row with no blockers is picked for dispatch by pickForProject', () => {
|
|
82
|
+
assert.ok(JOB_STATUSES.includes('pending'));
|
|
83
|
+
const job = { slug: 'pending-row', cwd: '/home/user/project', status: 'pending' };
|
|
84
|
+
const { batch } = pickForProject([job], new Set(), 5);
|
|
85
|
+
assert.ok(batch.some((j) => j.slug === 'pending-row'), 'pending row must be dispatch-eligible with no blockers');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('running: a running row whose pid is provably dead is reaped by selectReapableJobs', () => {
|
|
89
|
+
assert.ok(JOB_STATUSES.includes('running'));
|
|
90
|
+
const job = {
|
|
91
|
+
slug: 'dead-running-row',
|
|
92
|
+
cwd: '/home/user/project',
|
|
93
|
+
status: 'running',
|
|
94
|
+
runtime: { pid: 12345 },
|
|
95
|
+
};
|
|
96
|
+
const { reapable } = selectReapableJobs([job], Date.now(), { pidAlive: () => false, grace: 5 * MIN_MS });
|
|
97
|
+
assert.ok(reapable.some((r) => r.slug === 'dead-running-row'), 'a running row with a dead pid must be reapable');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('investigating: a stranded investigation (stale, no live pid) is found by findStrandedInvestigations', () => {
|
|
101
|
+
assert.ok(JOB_STATUSES.includes('investigating'));
|
|
102
|
+
const job = {
|
|
103
|
+
slug: 'stranded-investigation',
|
|
104
|
+
cwd: '/home/user/project',
|
|
105
|
+
status: 'investigating',
|
|
106
|
+
statusHistory: [{ to: 'investigating', from: 'failed', at: new Date(Date.now() - (INVESTIGATION_MAX_MS + 5 * MIN_MS)).toISOString() }],
|
|
107
|
+
};
|
|
108
|
+
const found = findStrandedInvestigations([job], Date.now(), INVESTIGATION_MAX_MS, () => false);
|
|
109
|
+
assert.ok(found.some((f) => f.slug === 'stranded-investigation'), 'a stale investigating row with no live pid must be found');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('failed: a stale failed row under its auto-reset cap is selected by selectFailedAutoResetTargets', () => {
|
|
113
|
+
assert.ok(JOB_STATUSES.includes('failed'));
|
|
114
|
+
const job = {
|
|
115
|
+
slug: 'stale-failed-row',
|
|
116
|
+
cwd: '/home/user/project',
|
|
117
|
+
status: 'failed',
|
|
118
|
+
failedAutoResetAttempts: FAILED_AUTORESET_CAP - 1,
|
|
119
|
+
statusHistory: [{ to: 'failed', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
|
|
120
|
+
};
|
|
121
|
+
const found = selectFailedAutoResetTargets([job], Date.now(), 10 * MIN_MS);
|
|
122
|
+
assert.ok(found.some((f) => f.slug === 'stale-failed-row'), 'a stale failed row under the cap must be auto-reset-selectable');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('needs_review: an exhausted, stale needs_review row is selected by selectExhaustedNeedsReviewTargets', () => {
|
|
126
|
+
assert.ok(JOB_STATUSES.includes('needs_review'));
|
|
127
|
+
const job = {
|
|
128
|
+
slug: 'exhausted-needs-review-row',
|
|
129
|
+
cwd: '/home/user/project',
|
|
130
|
+
status: 'needs_review',
|
|
131
|
+
autoFixAttempted: true,
|
|
132
|
+
autoFixOutcome: 'no-plan',
|
|
133
|
+
autoFixRetries: 1,
|
|
134
|
+
exhaustedResolveAttempts: NEEDS_REVIEW_RESOLVE_CAP,
|
|
135
|
+
statusHistory: [{ to: 'needs_review', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
|
|
136
|
+
};
|
|
137
|
+
const found = selectExhaustedNeedsReviewTargets([job], Date.now(), 30 * MIN_MS);
|
|
138
|
+
assert.ok(found.some((f) => f.slug === 'exhausted-needs-review-row'), 'an exhausted, stale needs_review row must be auto-resolve-selectable');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('quarantined: a stale quarantined row under its resolve cap is selected by selectQuarantineAutoResolveTargets', () => {
|
|
142
|
+
assert.ok(JOB_STATUSES.includes('quarantined'));
|
|
143
|
+
const job = {
|
|
144
|
+
slug: 'stale-quarantined-row',
|
|
145
|
+
cwd: '/home/user/project',
|
|
146
|
+
status: 'quarantined',
|
|
147
|
+
quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP - 1,
|
|
148
|
+
statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 60 * MIN_MS).toISOString() }],
|
|
149
|
+
};
|
|
150
|
+
const found = selectQuarantineAutoResolveTargets([job], Date.now(), 30 * MIN_MS);
|
|
151
|
+
assert.ok(found.some((f) => f.slug === 'stale-quarantined-row'), 'a stale quarantined row under the cap must be auto-resolve-selectable');
|
|
152
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-quarantine-autoresolve.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* Covers the bounded automatic exit for a quarantined row that never
|
|
5
|
+
* received a `createdVia` provenance stamp (this PRD): the pure selector
|
|
6
|
+
* `selectQuarantineAutoResolveTargets`, the async applier
|
|
7
|
+
* `autoResolveQuarantine` (which re-reads the PRD file's frontmatter fresh
|
|
8
|
+
* from disk immediately before transitioning, so it can never race
|
|
9
|
+
* reconcile()'s own adopt path), and the kill switch
|
|
10
|
+
* `quarantineAutoResolveDisabled`.
|
|
11
|
+
*
|
|
12
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — same
|
|
13
|
+
* reason as scheduler-failed-autoreset.test.cjs (appendAuditEvent writes
|
|
14
|
+
* under $HOME/.claude/session-manager/audit-log.jsonl).
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const assert = require('node:assert/strict');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
|
|
26
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'quarantine-autoresolve-test-'));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
selectQuarantineAutoResolveTargets,
|
|
31
|
+
autoResolveQuarantine,
|
|
32
|
+
QUARANTINE_RESOLVE_CAP,
|
|
33
|
+
quarantineAutoResolveDisabled,
|
|
34
|
+
} = require('../scheduler.cjs');
|
|
35
|
+
|
|
36
|
+
const MIN_MS = 60_000;
|
|
37
|
+
const THRESHOLD_MS = 60 * MIN_MS;
|
|
38
|
+
|
|
39
|
+
function quarantinedJob(overrides = {}) {
|
|
40
|
+
return {
|
|
41
|
+
slug: 'quarantined-row',
|
|
42
|
+
cwd: '/home/user/project',
|
|
43
|
+
status: 'quarantined',
|
|
44
|
+
statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 90 * MIN_MS).toISOString() }],
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function registerActiveProject(cwd) {
|
|
50
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
51
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
52
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
53
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeFixtureProject(prefix) {
|
|
57
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
58
|
+
registerActiveProject(cwd);
|
|
59
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'test-epic-1', 'prds');
|
|
60
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
61
|
+
return { cwd, prdsDir };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- selectQuarantineAutoResolveTargets ---
|
|
65
|
+
|
|
66
|
+
test('a fresh quarantined row (under threshold) is not selected', () => {
|
|
67
|
+
const job = quarantinedJob({
|
|
68
|
+
statusHistory: [{ to: 'quarantined', at: new Date(Date.now() - 2 * MIN_MS).toISOString() }],
|
|
69
|
+
});
|
|
70
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a non-quarantined status is never selected', () => {
|
|
74
|
+
const job = quarantinedJob({ status: 'failed' });
|
|
75
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('a quarantined row with no statusHistory entry cannot have its age proven, and is skipped', () => {
|
|
79
|
+
const job = quarantinedJob({ statusHistory: [] });
|
|
80
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a quarantined row past threshold, under the cap, is selected', () => {
|
|
84
|
+
const job = quarantinedJob({ quarantineResolveAttempts: 0 });
|
|
85
|
+
const found = selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS);
|
|
86
|
+
assert.equal(found.length, 1);
|
|
87
|
+
assert.equal(found[0].slug, 'quarantined-row');
|
|
88
|
+
assert.ok(found[0].ageMs >= 90 * MIN_MS - 1000);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a quarantined row that already spent its resolve cap is excluded', () => {
|
|
92
|
+
const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
|
|
93
|
+
assert.equal(selectQuarantineAutoResolveTargets([job], Date.now(), THRESHOLD_MS).length, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('quarantineAutoResolveDisabled reflects SM_QUARANTINE_AUTORESOLVE_DISABLE', () => {
|
|
97
|
+
const saved = process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
98
|
+
try {
|
|
99
|
+
delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
100
|
+
assert.equal(quarantineAutoResolveDisabled(), false);
|
|
101
|
+
process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = '1';
|
|
102
|
+
assert.equal(quarantineAutoResolveDisabled(), true);
|
|
103
|
+
} finally {
|
|
104
|
+
if (saved === undefined) delete process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE;
|
|
105
|
+
else process.env.SM_QUARANTINE_AUTORESOLVE_DISABLE = saved;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// --- autoResolveQuarantine: the applier ---
|
|
110
|
+
|
|
111
|
+
test('a quarantined row whose PRD file still has no createdVia is auto-resolved to skipped', async () => {
|
|
112
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-noprovenance-');
|
|
113
|
+
try {
|
|
114
|
+
fs.writeFileSync(
|
|
115
|
+
path.join(prdsDir, 'quarantined-row.md'),
|
|
116
|
+
`---\ntitle: Unstamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\n---\n\n# Goal\nDo the thing.\n`,
|
|
117
|
+
'utf8',
|
|
118
|
+
);
|
|
119
|
+
const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
|
|
120
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
121
|
+
assert.equal(outcome, 'skipped');
|
|
122
|
+
assert.equal(job.status, 'skipped');
|
|
123
|
+
assert.equal(job.quarantineResolveAttempts, 1);
|
|
124
|
+
assert.ok(typeof job.error === 'string' && /createdVia/.test(job.error));
|
|
125
|
+
} finally {
|
|
126
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a quarantined row whose PRD file HAS been stamped with createdVia is left alone — reconcile owns it now', async () => {
|
|
131
|
+
const { cwd, prdsDir } = makeFixtureProject('sm-quarantine-autoresolve-stamped-');
|
|
132
|
+
try {
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(prdsDir, 'quarantined-row.md'),
|
|
135
|
+
`---\ntitle: Stamped PRD\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
136
|
+
'utf8',
|
|
137
|
+
);
|
|
138
|
+
const job = quarantinedJob({ cwd, quarantineResolveAttempts: 0 });
|
|
139
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
140
|
+
assert.equal(outcome, null);
|
|
141
|
+
assert.equal(job.status, 'quarantined'); // untouched — the adopt path owns the next pass
|
|
142
|
+
assert.equal(job.quarantineResolveAttempts, 0);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('autoResolveQuarantine race-guards against a row that moved off quarantined', async () => {
|
|
149
|
+
const job = quarantinedJob({ status: 'pending', quarantineResolveAttempts: 0 });
|
|
150
|
+
assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
|
|
151
|
+
assert.equal(job.status, 'pending'); // untouched
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('autoResolveQuarantine race-guards against a row whose resolve cap is already spent', async () => {
|
|
155
|
+
const job = quarantinedJob({ quarantineResolveAttempts: QUARANTINE_RESOLVE_CAP });
|
|
156
|
+
assert.equal(await autoResolveQuarantine(job, 90 * MIN_MS), null);
|
|
157
|
+
assert.equal(job.status, 'quarantined'); // untouched
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('a quarantined row whose PRD file is missing entirely is treated as still lacking provenance and auto-resolved', async () => {
|
|
161
|
+
const job = quarantinedJob({ cwd: '/nonexistent/project/path', quarantineResolveAttempts: 0 });
|
|
162
|
+
const outcome = await autoResolveQuarantine(job, 90 * MIN_MS);
|
|
163
|
+
assert.equal(outcome, 'skipped');
|
|
164
|
+
assert.equal(job.status, 'skipped');
|
|
165
|
+
});
|