claude-code-session-manager 0.78.0 → 0.80.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-13pfo8uY.js → AgentLibrary-zS3jw_1e.js} +1 -1
- package/dist/assets/{DataModel-SUyQbFlg.js → DataModel-Cy_vxTpi.js} +1 -1
- package/dist/assets/{History-2GJMS703.js → History-C6JRuqfT.js} +1 -1
- package/dist/assets/{Hooks-DM2nS3RT.js → Hooks-BafPy9mB.js} +1 -1
- package/dist/assets/{HostBilko-BLeC-lpp.js → HostBilko-BZwhQOFt.js} +1 -1
- package/dist/assets/{Library-BaRkU9m0.js → Library-C8JDDliz.js} +1 -1
- package/dist/assets/{ListDetail-D5scjSKq.js → ListDetail-CqiOdwLc.js} +1 -1
- package/dist/assets/{MarkdownEditor-B1lgAo9T.js → MarkdownEditor-CyLyP67L.js} +1 -1
- package/dist/assets/{McpServers-BzyThQSM.js → McpServers-BzMv-_84.js} +1 -1
- package/dist/assets/{Memory-7UdaOTtl.js → Memory-DSBYQdJR.js} +1 -1
- package/dist/assets/{Panel-JbTMaOPq.js → Panel-CLUhkNNA.js} +1 -1
- package/dist/assets/{Permissions-UBam0bJG.js → Permissions-BfC2-HN4.js} +1 -1
- package/dist/assets/{Plugins-B3gUDkeb.js → Plugins-BKi40jT5.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CeHOub7m.js → ProvenanceBadge-BzFw4KhD.js} +1 -1
- package/dist/assets/{SaveBar-BcvQEq6h.js → SaveBar-avk2p9jv.js} +1 -1
- package/dist/assets/{Scheduler-Dc5qiP24.js → Scheduler-Bf_6MdJo.js} +7 -7
- package/dist/assets/{ScopeSwitcher-BvGQmw4Y.js → ScopeSwitcher-C-RwYUVZ.js} +1 -1
- package/dist/assets/{Settings-C2dEFb-v.js → Settings-Djd8OoBA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CIwlosBc.js → SkillReferenceGraph-DuogY6s7.js} +1 -1
- package/dist/assets/{Skills-C0GzzVrQ.js → Skills-D_qAqxZ_.js} +1 -1
- package/dist/assets/{SystemPrompt-mtGPK8zo.js → SystemPrompt-DbHFLQV3.js} +1 -1
- package/dist/assets/{TagLibrary-DX54-mpd.js → TagLibrary-C2y91BT0.js} +1 -1
- package/dist/assets/{TiptapBody-yADC2RWE.js → TiptapBody-D9iz4xQx.js} +1 -1
- package/dist/assets/{Toggle-CRxaCYLI.js → Toggle-BGnFL2E5.js} +1 -1
- package/dist/assets/{index-D6ymGESc.js → index-_2ARyFDj.js} +4 -4
- package/dist/assets/{settingsSchema-TtMvT5Sx.js → settingsSchema-JK15eJU8.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +10 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +66 -0
- package/src/main/__tests__/prdCreate.test.cjs +133 -8
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +136 -0
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +160 -0
- package/src/main/__tests__/queueHistory.test.cjs +33 -0
- package/src/main/__tests__/runLogRetention.test.cjs +59 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +1 -0
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +73 -1
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +17 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +20 -0
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +199 -0
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +222 -0
- package/src/main/__tests__/scheduler-never-stop.test.cjs +157 -0
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +123 -0
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +158 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +30 -0
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +51 -0
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +254 -0
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +117 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -2
- package/src/main/ipcSchemas.cjs +15 -1
- package/src/main/lib/__tests__/gitWorktree.test.cjs +129 -10
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +90 -1
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +59 -7
- package/src/main/lib/depSlugResolve.cjs +72 -0
- package/src/main/lib/epicWorktreeMerge.cjs +3 -3
- package/src/main/lib/epicWorktreeMint.cjs +17 -5
- package/src/main/lib/fixPlanSlug.cjs +62 -0
- package/src/main/lib/gitWorktree.cjs +97 -12
- package/src/main/lib/jobDirtFilter.cjs +54 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/prdCreate.cjs +84 -5
- package/src/main/lib/prdFrontmatter.cjs +56 -8
- package/src/main/lib/queueHistory.cjs +50 -5
- package/src/main/lib/rateLimitDetect.cjs +35 -0
- package/src/main/lib/reaperHelpers.cjs +31 -6
- package/src/main/lib/runLogRetention.cjs +82 -4
- package/src/main/lib/scheduleJobTransitions.cjs +12 -2
- package/src/main/lib/schedulerBatch.cjs +181 -23
- package/src/main/scheduler/prdParser.cjs +7 -0
- package/src/main/scheduler.cjs +1165 -70
- package/src/preload/api.d.ts +8 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-never-stop.test.cjs — the invariant: if a queue holds ready PRDs,
|
|
3
|
+
* something must drive them.
|
|
4
|
+
*
|
|
5
|
+
* Every stall observed in this codebase was a different CAUSE with the same
|
|
6
|
+
* SHAPE — ready rows, nothing running, nobody ticking:
|
|
7
|
+
* - a rate-limited exit stamped terminal 'failed' (2026-09-05: 42 rows)
|
|
8
|
+
* - a spin loop past the manual-clear cooldown
|
|
9
|
+
* - a worktree merge-back leaving the project on a job branch
|
|
10
|
+
* - a job parked needs_review with no fix plan
|
|
11
|
+
* - app churn under session-manager-operations/ counted as unfinished work
|
|
12
|
+
* - the billing meter itself 429ing so utilization never reported
|
|
13
|
+
* Guarding each cause individually always lags the next one, so the watchdog
|
|
14
|
+
* guards the shape.
|
|
15
|
+
*
|
|
16
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/scheduler-never-stop.test.cjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
// vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
|
|
22
|
+
import { test } from 'vitest';
|
|
23
|
+
const assert = require('node:assert/strict');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const path = require('node:path');
|
|
27
|
+
|
|
28
|
+
// HOME before require: every state path is baked from os.homedir() at load.
|
|
29
|
+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'never-stop-test-'));
|
|
30
|
+
|
|
31
|
+
const {
|
|
32
|
+
classifyQueueStarvation,
|
|
33
|
+
QUEUE_STARVATION_MS,
|
|
34
|
+
stripAppOwnedChurn,
|
|
35
|
+
} = require('../scheduler.cjs');
|
|
36
|
+
|
|
37
|
+
const CWD = '/home/bilko/Projects/starry-night-ships';
|
|
38
|
+
const NOW = Date.parse('2026-09-05T23:00:00.000Z');
|
|
39
|
+
const idleFor = (ms) => NOW - ms;
|
|
40
|
+
const LONG = QUEUE_STARVATION_MS + 60_000;
|
|
41
|
+
|
|
42
|
+
const base = (over = {}) => ({
|
|
43
|
+
jobs: [{ slug: 'a', cwd: CWD, status: 'pending', dependsOn: [] }],
|
|
44
|
+
paused: null,
|
|
45
|
+
runningCount: 0,
|
|
46
|
+
lastRunAtMs: idleFor(LONG),
|
|
47
|
+
now: NOW,
|
|
48
|
+
...over,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── the invariant ────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
test('ready work + nothing running + long idle => starved, and a tick would help', () => {
|
|
54
|
+
const v = classifyQueueStarvation(base());
|
|
55
|
+
assert.equal(v.kind, 'starved');
|
|
56
|
+
assert.equal(v.pending, 1);
|
|
57
|
+
assert.equal(v.dispatchable, 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('the real incident shape: 40 ready rows, 0 running, idle for hours', () => {
|
|
61
|
+
const jobs = Array.from({ length: 40 }, (_, i) => ({
|
|
62
|
+
slug: `${207 + i}-step`, cwd: CWD, status: 'pending', dependsOn: [],
|
|
63
|
+
}));
|
|
64
|
+
const v = classifyQueueStarvation(base({ jobs, lastRunAtMs: idleFor(3 * 60 * 60_000) }));
|
|
65
|
+
assert.equal(v.kind, 'starved');
|
|
66
|
+
assert.equal(v.dispatchable, 40);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── it must NOT fire when the queue is legitimately quiet ────────────────────
|
|
70
|
+
|
|
71
|
+
test('a running job means the queue is flowing — never starved', () => {
|
|
72
|
+
assert.equal(classifyQueueStarvation(base({ runningCount: 1 })), null);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('no pending rows is not a stall', () => {
|
|
76
|
+
assert.equal(classifyQueueStarvation(base({ jobs: [{ slug: 'a', cwd: CWD, status: 'completed' }] })), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('paused is a DECISION, not a stall — the watchdog must never override it', () => {
|
|
80
|
+
assert.equal(classifyQueueStarvation(base({ paused: { reason: 'rate_limit', resumeAt: null } })), null);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('inside the idle threshold the normal path gets its chance first', () => {
|
|
84
|
+
assert.equal(classifyQueueStarvation(base({ lastRunAtMs: idleFor(QUEUE_STARVATION_MS - 1000) })), null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('a never-recorded lastRunAt counts as infinitely idle, not as healthy', () => {
|
|
88
|
+
const v = classifyQueueStarvation(base({ lastRunAtMs: NaN }));
|
|
89
|
+
assert.equal(v.kind, 'starved');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── blocked is a DIFFERENT verdict from starved ──────────────────────────────
|
|
93
|
+
|
|
94
|
+
test('every pending row behind a terminal dep => blocked, not starved (a tick cannot help)', () => {
|
|
95
|
+
const jobs = [
|
|
96
|
+
{ slug: '206-lava-ape', cwd: CWD, status: 'failed', dependsOn: [] },
|
|
97
|
+
{ slug: '207-body', cwd: CWD, status: 'pending', dependsOn: ['206-lava-ape'] },
|
|
98
|
+
{ slug: '208-docs', cwd: CWD, status: 'pending', dependsOn: ['207-body'] },
|
|
99
|
+
];
|
|
100
|
+
const v = classifyQueueStarvation(base({ jobs }));
|
|
101
|
+
assert.equal(v.kind, 'blocked');
|
|
102
|
+
assert.equal(v.pending, 2);
|
|
103
|
+
assert.equal(v.dispatchable, 0);
|
|
104
|
+
assert.deepEqual(v.blockedChains[0].blockedBy, ['206-lava-ape']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('one dispatchable row among blocked ones still counts as starved — the queue can make progress', () => {
|
|
108
|
+
const jobs = [
|
|
109
|
+
{ slug: 'x-dead', cwd: CWD, status: 'failed', dependsOn: [] },
|
|
110
|
+
{ slug: 'y-blocked', cwd: CWD, status: 'pending', dependsOn: ['x-dead'] },
|
|
111
|
+
{ slug: 'z-ready', cwd: CWD, status: 'pending', dependsOn: [] },
|
|
112
|
+
];
|
|
113
|
+
const v = classifyQueueStarvation(base({ jobs }));
|
|
114
|
+
assert.equal(v.kind, 'starved');
|
|
115
|
+
assert.equal(v.dispatchable, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a project blocked while ANOTHER project has ready work is still starved overall', () => {
|
|
119
|
+
const jobs = [
|
|
120
|
+
{ slug: 'a-dead', cwd: '/p/one', status: 'failed', dependsOn: [] },
|
|
121
|
+
{ slug: 'b-blocked', cwd: '/p/one', status: 'pending', dependsOn: ['a-dead'] },
|
|
122
|
+
{ slug: 'c-ready', cwd: '/p/two', status: 'pending', dependsOn: [] },
|
|
123
|
+
];
|
|
124
|
+
assert.equal(classifyQueueStarvation(base({ jobs })).kind, 'starved');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── app-owned churn must never be blamed on a job ────────────────────────────
|
|
128
|
+
|
|
129
|
+
test('stripAppOwnedChurn drops session-manager-operations churn, keeps real source', () => {
|
|
130
|
+
const out = stripAppOwnedChurn([
|
|
131
|
+
'src/main/scheduler.cjs',
|
|
132
|
+
'session-manager-operations/scheduler/state/queue.json',
|
|
133
|
+
'session-manager-operations/scheduler/state/history.jsonl',
|
|
134
|
+
'session-manager-operations/prompt-sessions/active-index.json',
|
|
135
|
+
'dashboard/panels/outcome-stats.jsx',
|
|
136
|
+
]);
|
|
137
|
+
assert.deepEqual(out, ['src/main/scheduler.cjs', 'dashboard/panels/outcome-stats.jsx']);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('stripAppOwnedChurn preserves the null contract (git status unavailable != left nothing)', () => {
|
|
141
|
+
assert.equal(stripAppOwnedChurn(null), null);
|
|
142
|
+
assert.equal(stripAppOwnedChurn(undefined), undefined);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('stripAppOwnedChurn only matches a whole path segment, never a substring', () => {
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
stripAppOwnedChurn(['docs/session-manager-operations-guide.md', 'a/session-manager-operations/x.json']),
|
|
148
|
+
['docs/session-manager-operations-guide.md'],
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a job that leaves REAL source dirty still parks — the filter must not excuse that', () => {
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
stripAppOwnedChurn(['session-manager-operations/scheduler/state/queue.json', 'scripts/arena/titan.gd']),
|
|
155
|
+
['scripts/arena/titan.gd'],
|
|
156
|
+
);
|
|
157
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-no-orphan-run-dir.test.cjs — PRD 1120: pickRunDir() must not
|
|
3
|
+
* mkdir a run directory until a dispatch actually commits (executeJob's
|
|
4
|
+
* first write). A dispatch that aborts inside spawnJob before that point —
|
|
5
|
+
* e.g. sessionSlots.acquire() returning null, the earliest exit in spawnJob
|
|
6
|
+
* — must leave RUNS_DIR untouched.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
let tmpHome;
|
|
19
|
+
let originalHome;
|
|
20
|
+
let originalSlots;
|
|
21
|
+
let scheduler;
|
|
22
|
+
let sessionSlots;
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
originalHome = process.env.HOME;
|
|
26
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-no-orphan-rundir-'));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
originalSlots = process.env.SM_SESSION_SLOTS;
|
|
29
|
+
scheduler = require('../scheduler.cjs');
|
|
30
|
+
sessionSlots = require('../lib/sessionSlots.cjs');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterAll(() => {
|
|
34
|
+
process.env.HOME = originalHome;
|
|
35
|
+
if (originalSlots === undefined) delete process.env.SM_SESSION_SLOTS;
|
|
36
|
+
else process.env.SM_SESSION_SLOTS = originalSlots;
|
|
37
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
sessionSlots.__resetForTests();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function runsDirEntryCount() {
|
|
45
|
+
const runsDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs');
|
|
46
|
+
try {
|
|
47
|
+
return fs.readdirSync(runsDir).length;
|
|
48
|
+
} catch {
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('pickRunDir() allocates a runId/dir pair without creating the directory', () => {
|
|
54
|
+
const before = runsDirEntryCount();
|
|
55
|
+
const { runId, dir } = scheduler.pickRunDir();
|
|
56
|
+
expect(runId).toBeTruthy();
|
|
57
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
58
|
+
expect(runsDirEntryCount()).toBe(before);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a dispatch that bails at slot-acquisition (earliest spawnJob exit) leaves no directory under RUNS_DIR', async () => {
|
|
62
|
+
// Force sessionSlots.acquire() to return null immediately — no slots at all.
|
|
63
|
+
process.env.SM_SESSION_SLOTS = '0';
|
|
64
|
+
const before = runsDirEntryCount();
|
|
65
|
+
|
|
66
|
+
const slug = `1120-slot-miss-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
67
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-no-orphan-rundir-project-'));
|
|
68
|
+
try {
|
|
69
|
+
await scheduler.spawnJob({ slug, cwd: projectCwd }, `run-${slug}`, path.join(
|
|
70
|
+
tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`
|
|
71
|
+
), projectCwd);
|
|
72
|
+
|
|
73
|
+
expect(runsDirEntryCount()).toBe(before);
|
|
74
|
+
expect(fs.existsSync(path.join(
|
|
75
|
+
tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', `run-${slug}`
|
|
76
|
+
))).toBe(false);
|
|
77
|
+
} finally {
|
|
78
|
+
delete process.env.SM_SESSION_SLOTS;
|
|
79
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-rate-limit-cooldown-freshness.test.cjs — PRD 1119.
|
|
3
|
+
*
|
|
4
|
+
* setPaused()'s manual-override cooldown used to suppress WRITING any pause
|
|
5
|
+
* for 5 minutes after a human Resume/Run now, unconditionally — even when
|
|
6
|
+
* the triggering rate-limit observation came from a run that started AFTER
|
|
7
|
+
* the manual clear (i.e. brand-new evidence, not the stale auto-detection
|
|
8
|
+
* the cooldown exists to ignore). Combined with the halt-reset that resets a
|
|
9
|
+
* rate-limited job straight back to 'pending', this produced a hot dispatch
|
|
10
|
+
* loop: dispatch -> 429 in seconds -> reset to pending -> pause suppressed
|
|
11
|
+
* -> dispatch again, on a ~12s cadence, until the cooldown itself expired.
|
|
12
|
+
*
|
|
13
|
+
* isCooldownSuppressed() is the pure predicate this PRD extracts so both
|
|
14
|
+
* directions of the fix (fresh observation bypasses the cooldown; stale one
|
|
15
|
+
* stays suppressed) are directly unit-testable without touching queue.json,
|
|
16
|
+
* mutate(), or any fs state. nextRapidRateLimitCount() is the pure reducer
|
|
17
|
+
* behind the independent rapid-repeat hard-pause cap (a second circuit
|
|
18
|
+
* breaker for when the computed resumeAt is itself stale/wrong and would
|
|
19
|
+
* otherwise let the resume timer keep re-clearing the pause every ~30s,
|
|
20
|
+
* with every subsequent dispatch genuinely "fresh").
|
|
21
|
+
*
|
|
22
|
+
* Run: timeout 60 npx vitest run src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
import { test, expect } from 'vitest';
|
|
28
|
+
const {
|
|
29
|
+
isCooldownSuppressed,
|
|
30
|
+
nextRapidRateLimitCount,
|
|
31
|
+
CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD,
|
|
32
|
+
RAPID_RATE_LIMIT_WINDOW_MS,
|
|
33
|
+
MANUAL_PAUSE_COOLDOWN_MS,
|
|
34
|
+
} = require('../scheduler.cjs');
|
|
35
|
+
|
|
36
|
+
const clearedAt = 1_000_000;
|
|
37
|
+
|
|
38
|
+
test('a rate_limit pause triggered by a run that STARTED AFTER the manual clear engages regardless of the cooldown', () => {
|
|
39
|
+
const now = clearedAt + 60_000; // well inside the 5-minute cooldown window
|
|
40
|
+
const observedAt = clearedAt + 5_000; // the triggering run started after the clear
|
|
41
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt, force: false });
|
|
42
|
+
expect(suppressed).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a rate_limit pause triggered by an observation OLDER than the manual clear stays suppressed', () => {
|
|
46
|
+
const now = clearedAt + 60_000;
|
|
47
|
+
const observedAt = clearedAt - 5_000; // stale: this run started before the human cleared the pause
|
|
48
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt, force: false });
|
|
49
|
+
expect(suppressed).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('no observedAt at all (unrelated pause reasons, e.g. auth/network) is treated as stale — unchanged prior behavior', () => {
|
|
53
|
+
const now = clearedAt + 60_000;
|
|
54
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: null, force: false });
|
|
55
|
+
expect(suppressed).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('once the cooldown window itself has elapsed, suppression lifts regardless of freshness', () => {
|
|
59
|
+
const now = clearedAt + MANUAL_PAUSE_COOLDOWN_MS + 1;
|
|
60
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: null, force: false });
|
|
61
|
+
expect(suppressed).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('no manual clear on record never suppresses', () => {
|
|
65
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: null, now: Date.now(), observedAt: null, force: false });
|
|
66
|
+
expect(suppressed).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('force always bypasses the cooldown, fresh or not', () => {
|
|
70
|
+
const now = clearedAt + 1_000;
|
|
71
|
+
const staleObservedAt = clearedAt - 5_000;
|
|
72
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now, observedAt: staleObservedAt, force: true });
|
|
73
|
+
expect(suppressed).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ---------- rapid-repeat consecutive counter ----------
|
|
77
|
+
|
|
78
|
+
test('a rate-limited run under the rapid window increments the counter', () => {
|
|
79
|
+
expect(nextRapidRateLimitCount(0, { rateLimited: true, durationMs: 4_000 })).toBe(1);
|
|
80
|
+
expect(nextRapidRateLimitCount(1, { rateLimited: true, durationMs: 4_000 })).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a rate-limited run that ran for >= the rapid window does not increment the counter', () => {
|
|
84
|
+
expect(nextRapidRateLimitCount(1, { rateLimited: true, durationMs: RAPID_RATE_LIMIT_WINDOW_MS })).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('any non-rate-limited outcome resets the counter to 0', () => {
|
|
88
|
+
expect(nextRapidRateLimitCount(2, { rateLimited: false, durationMs: 999_999 })).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('driving the counter to the documented threshold engages the hard-pause condition', () => {
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (let i = 0; i < CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD; i += 1) {
|
|
94
|
+
count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
|
|
95
|
+
}
|
|
96
|
+
expect(count).toBe(CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
|
|
97
|
+
expect(count >= CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('reaching the threshold forces the pause through even against a STALE observation still inside the cooldown window', () => {
|
|
101
|
+
// This is the scenario the hard cap exists for: freshness alone is not
|
|
102
|
+
// enough when the computed resumeAt is itself stale/wrong and the resume
|
|
103
|
+
// timer keeps re-clearing the pause, so a LATER dispatch's observation can
|
|
104
|
+
// legitimately look "stale" relative to the most recent clear. The
|
|
105
|
+
// rapid-repeat counter is an independent, unconditional circuit breaker.
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (let i = 0; i < CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD; i += 1) {
|
|
108
|
+
count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
|
|
109
|
+
}
|
|
110
|
+
const force = count >= CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD;
|
|
111
|
+
const staleObservedAt = clearedAt - 999_999;
|
|
112
|
+
const suppressed = isCooldownSuppressed({ pauseClearedManuallyAt: clearedAt, now: clearedAt + 1_000, observedAt: staleObservedAt, force });
|
|
113
|
+
expect(suppressed).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('one non-rate-limited run in between resets the streak so the hard pause never engages', () => {
|
|
117
|
+
let count = 0;
|
|
118
|
+
count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
|
|
119
|
+
count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
|
|
120
|
+
count = nextRapidRateLimitCount(count, { rateLimited: false, durationMs: 60_000 }); // job actually ran fine
|
|
121
|
+
count = nextRapidRateLimitCount(count, { rateLimited: true, durationMs: 3_000 });
|
|
122
|
+
expect(count).toBeLessThan(CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
|
|
123
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-rate-limit-spin-guard.test.cjs — PRD 1119 end-to-end repro of the
|
|
3
|
+
* 2026-09-05 incident: a human pressed Resume/Run now on job 204, and for the
|
|
4
|
+
* next hour the scheduler dispatched it, watched it 429 in seconds, reset it
|
|
5
|
+
* to pending (halt-reset), and dispatched it again — 291 times, one every
|
|
6
|
+
* ~12s — because setPaused()'s manual-override cooldown suppressed WRITING
|
|
7
|
+
* the rate_limit pause for the whole 5-minute window, even though every one
|
|
8
|
+
* of those 429s was a brand-new observation from a run that started AFTER
|
|
9
|
+
* the human's clear.
|
|
10
|
+
*
|
|
11
|
+
* This drives the REAL dispatch path (spawnJob, via a stub `claude` binary
|
|
12
|
+
* that always 429s in well under a second) repeatedly, immediately after a
|
|
13
|
+
* simulated manual Resume, and asserts the scheduler engages a genuine pause
|
|
14
|
+
* almost immediately — never anywhere near the 291-dispatch runaway, and
|
|
15
|
+
* never past the documented CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD hard cap
|
|
16
|
+
* even in the worst case. Compressed in wall-clock time (a stub exits in
|
|
17
|
+
* milliseconds, not the ~12s a real `claude -p` round trip took) — the
|
|
18
|
+
* assertion is on DISPATCH COUNT, which is what the fix actually bounds,
|
|
19
|
+
* not on wall-clock duration.
|
|
20
|
+
*
|
|
21
|
+
* Run: timeout 60 npx vitest run src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { execFileSync } = require('node:child_process');
|
|
31
|
+
|
|
32
|
+
let tmpHome;
|
|
33
|
+
let originalHome;
|
|
34
|
+
let scheduler;
|
|
35
|
+
let queueStore;
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
originalHome = process.env.HOME;
|
|
39
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-rate-limit-spin-guard-'));
|
|
40
|
+
process.env.HOME = tmpHome;
|
|
41
|
+
// Worktree isolation is irrelevant to this repro (it's about pause
|
|
42
|
+
// suppression, not tree isolation) and each real `git worktree add` adds
|
|
43
|
+
// real wall-clock overhead this test does not need to pay 40 times over.
|
|
44
|
+
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
45
|
+
scheduler = require('../scheduler.cjs');
|
|
46
|
+
queueStore = require('../lib/queueStore.cjs');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
process.env.HOME = originalHome;
|
|
51
|
+
delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
52
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function git(args, cwd) {
|
|
60
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function initRepo(dir) {
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
git(['init', '-q'], dir);
|
|
66
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
67
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
68
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
69
|
+
git(['add', '-A'], dir);
|
|
70
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registerActiveProject(cwd) {
|
|
74
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
75
|
+
const slugDir = path.join(projectsDir, 'spin-guard-project-slug');
|
|
76
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
78
|
+
queueStore.bustCwdCache();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeProjectQueue(cwd, jobs) {
|
|
82
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
83
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
84
|
+
const queuePath = path.join(stateDir, 'queue.json');
|
|
85
|
+
fs.writeFileSync(queuePath, JSON.stringify({ jobs }, null, 2));
|
|
86
|
+
return queuePath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Stub `claude` binary: always dies as a rate limit, in well under 30s (same
|
|
90
|
+
// technique as scheduler-prd-persona-spawn.test.cjs's argv-marker stub) —
|
|
91
|
+
// emits the canonical 429 signal detectRateLimitInLog looks for, then exits
|
|
92
|
+
// non-zero almost instantly, and bumps a counter file each invocation so the
|
|
93
|
+
// test can assert exactly how many times it was actually spawned.
|
|
94
|
+
function writeAlways429ClaudeStub(counterPath) {
|
|
95
|
+
const stubPath = path.join(os.tmpdir(), `sm-claude-stub-429-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
96
|
+
const body = `
|
|
97
|
+
const fs = require('fs');
|
|
98
|
+
fs.appendFileSync(${JSON.stringify(counterPath)}, 'x');
|
|
99
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429 }) + '\\n');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
`;
|
|
102
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
103
|
+
return stubPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('a manual Resume immediately followed by a permanently-429ing job never produces a runaway dispatch loop', async () => {
|
|
107
|
+
const projectCwd = fs.mkdtempSync(path.join(tmpHome, 'spin-guard-project-'));
|
|
108
|
+
initRepo(projectCwd);
|
|
109
|
+
registerActiveProject(projectCwd);
|
|
110
|
+
const slug = `1119-spin-guard-${process.pid}`;
|
|
111
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'prds');
|
|
112
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
113
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Do the thing.', 'utf8');
|
|
114
|
+
|
|
115
|
+
const counterPath = path.join(tmpHome, 'dispatch-count.txt');
|
|
116
|
+
fs.writeFileSync(counterPath, '');
|
|
117
|
+
process.env.SM_CLAUDE_BIN = writeAlways429ClaudeStub(counterPath);
|
|
118
|
+
|
|
119
|
+
writeProjectQueue(projectCwd, [
|
|
120
|
+
{ slug, status: 'pending', cwd: projectCwd },
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
// The human just pressed Resume/Run now — this is the exact call clearPause
|
|
124
|
+
// makes from the IPC handlers, and it starts the 5-minute cooldown window.
|
|
125
|
+
await scheduler.clearPause('manual');
|
|
126
|
+
|
|
127
|
+
const job = { slug, cwd: projectCwd };
|
|
128
|
+
|
|
129
|
+
// Simulate the tick loop's dispatch decision being driven repeatedly "over
|
|
130
|
+
// ten minutes" — compressed here to however many cycles a permanently-429
|
|
131
|
+
// job can actually complete, since each cycle is a real (fast) subprocess
|
|
132
|
+
// spawn rather than a real 12s `claude -p` round trip. tickQueue()'s own
|
|
133
|
+
// paused early-return (unchanged by this PRD) is exactly what this loop
|
|
134
|
+
// reproduces by hand: stop dispatching the moment the queue is paused.
|
|
135
|
+
const MAX_TICKS = 40;
|
|
136
|
+
for (let i = 0; i < MAX_TICKS; i += 1) {
|
|
137
|
+
// eslint-disable-next-line no-await-in-loop
|
|
138
|
+
const state = await queueStore.readMerged();
|
|
139
|
+
if (state.paused) break;
|
|
140
|
+
const row = state.jobs.find((j) => j.slug === slug);
|
|
141
|
+
if (!row || row.status !== 'pending') break;
|
|
142
|
+
const runId = `run-${i}`;
|
|
143
|
+
const runDir = path.join(scheduler.RUNS_DIR, runId);
|
|
144
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
145
|
+
// eslint-disable-next-line no-await-in-loop
|
|
146
|
+
await scheduler.spawnJob(job, runId, runDir, projectCwd);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dispatchCount = fs.readFileSync(counterPath, 'utf8').length;
|
|
150
|
+
expect(dispatchCount).toBeLessThanOrEqual(scheduler.CONSECUTIVE_RAPID_RATE_LIMIT_THRESHOLD);
|
|
151
|
+
expect(dispatchCount).toBeGreaterThan(0);
|
|
152
|
+
expect(dispatchCount).toBeLessThan(10); // nowhere near the 291-dispatch incident
|
|
153
|
+
|
|
154
|
+
const finalState = await queueStore.readMerged();
|
|
155
|
+
expect(finalState.paused).toBeTruthy();
|
|
156
|
+
expect(finalState.paused.reason).toBe('rate_limit');
|
|
157
|
+
expect(finalState.jobs.find((j) => j.slug === slug).status).toBe('pending');
|
|
158
|
+
});
|
|
@@ -271,6 +271,36 @@ test('reapDeadRunningJobs: a failed reap with a persisted guardBaseline names th
|
|
|
271
271
|
assert.match(lastTransition.reason, /left 1 files uncommitted/);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
+
test('reapDeadRunningJobs: a rate-limited death (api_error_status:429) is reset to pending, not stamped failed, and the reason names the rate limit (PRD 1117)', async () => {
|
|
275
|
+
const projectCwd = path.join(tmpHome, 'h-project');
|
|
276
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
277
|
+
registerActiveProject(projectCwd);
|
|
278
|
+
|
|
279
|
+
const runId = 'run-rate-limited';
|
|
280
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
281
|
+
{
|
|
282
|
+
slug: 'rate-limited-job',
|
|
283
|
+
status: 'running',
|
|
284
|
+
cwd: projectCwd,
|
|
285
|
+
runId,
|
|
286
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid — the reaper won the race
|
|
287
|
+
},
|
|
288
|
+
]);
|
|
289
|
+
// Real-shape tail: a 429 result event, the exact signal the reaper
|
|
290
|
+
// previously had no branch for and stamped terminal 'failed'.
|
|
291
|
+
writeRunLog(runId, 'rate-limited-job', [
|
|
292
|
+
'{"type":"result","subtype":"success","is_error":true,"api_error_status":429,"rateLimitType":"seven_day","result":"You\'ve reached your Fable limit.","uuid":"x"}',
|
|
293
|
+
]);
|
|
294
|
+
|
|
295
|
+
await reapDeadRunningJobs();
|
|
296
|
+
|
|
297
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
298
|
+
const row = jobs.find((j) => j.slug === 'rate-limited-job');
|
|
299
|
+
assert.equal(row.status, 'pending', 'a rate-limited reap must be retryable, never terminal failed');
|
|
300
|
+
assert.match(row.error, /rate limit/i, 'the reset reason must name the rate limit, not read as a generic reap failure');
|
|
301
|
+
assert.equal(row.runtime, undefined);
|
|
302
|
+
});
|
|
303
|
+
|
|
274
304
|
test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the row has no persisted guardBaseline', async () => {
|
|
275
305
|
const projectCwd = path.join(tmpHome, 'f-project');
|
|
276
306
|
initRepo(projectCwd);
|
|
@@ -146,6 +146,57 @@ test('reconcile() exempts a fix-plan PRD (NN-fix-*) from quarantine even with no
|
|
|
146
146
|
}
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
test('reconcile() does NOT stamp investigationDepth for a createdVia-stamped PRD whose slug merely starts with "fix-" (PRD 1126/1131 regression)', async () => {
|
|
150
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-fake-fixplan-');
|
|
151
|
+
try {
|
|
152
|
+
// Mirrors PRD 1126: authored via scheduler_create_prd (createdVia stamped),
|
|
153
|
+
// slug happens to kebab-case into "fix-..." — must NOT be treated as a
|
|
154
|
+
// genuine scheduler-authored fix plan just because the name matches.
|
|
155
|
+
fs.writeFileSync(
|
|
156
|
+
path.join(prdsDir, '9005-fix-plan-death-reopens-parent.md'),
|
|
157
|
+
`---\ntitle: Fix plan death reopens parent\ncwd: ${cwd}\nestimateMinutes: 15\ncreatedVia: scheduler-api\nissuedAt: 2026-08-07T00:00:00.000Z\n---\n\n# Goal\nDo the thing.\n`,
|
|
158
|
+
'utf8',
|
|
159
|
+
);
|
|
160
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
161
|
+
|
|
162
|
+
queueStore.bustCwdCache();
|
|
163
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
164
|
+
await scheduler.reconcile(state);
|
|
165
|
+
|
|
166
|
+
const row = state.jobs.find((j) => j.slug === '9005-fix-plan-death-reopens-parent');
|
|
167
|
+
expect(row).toBeDefined();
|
|
168
|
+
expect(row.status).toBe('pending');
|
|
169
|
+
expect(row.isFixPlan).toBe(false);
|
|
170
|
+
expect(row.investigationDepth).toBeUndefined();
|
|
171
|
+
} finally {
|
|
172
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('reconcile() stamps isFixPlan + investigationDepth for a genuine spawnInvestigation-authored fix plan (explicit isFixPlan:true, no createdVia)', async () => {
|
|
177
|
+
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-genuine-fixplan-');
|
|
178
|
+
try {
|
|
179
|
+
fs.writeFileSync(
|
|
180
|
+
path.join(prdsDir, '9006-fix-something.md'),
|
|
181
|
+
`---\ntitle: Fix something\ncwd: ${cwd}\nestimateMinutes: 15\nisFixPlan: true\n---\n\n# Goal\nHeal it.\n`,
|
|
182
|
+
'utf8',
|
|
183
|
+
);
|
|
184
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
185
|
+
|
|
186
|
+
queueStore.bustCwdCache();
|
|
187
|
+
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
188
|
+
await scheduler.reconcile(state);
|
|
189
|
+
|
|
190
|
+
const row = state.jobs.find((j) => j.slug === '9006-fix-something');
|
|
191
|
+
expect(row).toBeDefined();
|
|
192
|
+
expect(row.status).toBe('pending');
|
|
193
|
+
expect(row.isFixPlan).toBe(true);
|
|
194
|
+
expect(row.investigationDepth).toBe(2);
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
149
200
|
test('reconcile() adopts a quarantined row to pending once its PRD file is stamped', async () => {
|
|
150
201
|
const { cwd, prdsDir, stateDir } = makeFixtureProject('sm-quarantine-adopt-');
|
|
151
202
|
try {
|