claude-code-session-manager 0.80.0 → 0.82.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-zS3jw_1e.js → AgentLibrary-pwlkAFb3.js} +1 -1
- package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BZK9PXFD.js} +1 -1
- package/dist/assets/{History-C6JRuqfT.js → History-BVRjxJjS.js} +1 -1
- package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CNuwHeGx.js} +1 -1
- package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-cjwNodhV.js} +1 -1
- package/dist/assets/{Library-C8JDDliz.js → Library-YPNm9W92.js} +1 -1
- package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CY4GM1Om.js} +1 -1
- package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BF4y2Jiz.js} +1 -1
- package/dist/assets/{McpServers-BzMv-_84.js → McpServers-CmBdWtX_.js} +1 -1
- package/dist/assets/{Memory-DSBYQdJR.js → Memory-CvkIXNl1.js} +1 -1
- package/dist/assets/{Panel-CLUhkNNA.js → Panel-D93o-sxe.js} +1 -1
- package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-DQipg16I.js} +1 -1
- package/dist/assets/{Plugins-BKi40jT5.js → Plugins-B6NwzPfK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-DACVJhrB.js} +1 -1
- package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-Cg4lbChb.js} +1 -1
- package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-Dr5ZcBLe.js} +1 -1
- package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-C-locvy0.js} +1 -1
- package/dist/assets/{Settings-Djd8OoBA.js → Settings-BVrAle90.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-DDzuYgSK.js} +1 -1
- package/dist/assets/{Skills-D_qAqxZ_.js → Skills-DFvhiOAQ.js} +1 -1
- package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-8PiTyUyL.js} +1 -1
- package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-DruUYaAc.js} +1 -1
- package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-Dr4a--42.js} +1 -1
- package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-B_EH2TFb.js} +1 -1
- package/dist/assets/{index-_2ARyFDj.js → index-DApB4DHS.js} +4 -4
- package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-BKa-xk8g.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -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__/machineProfile.test.cjs +134 -0
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +85 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/__tests__/telemetrySettings.test.cjs +178 -0
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/gitWorktree.cjs +20 -0
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/machineProfile.cjs +144 -0
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +16 -3
- package/src/main/lib/telemetrySettings.cjs +171 -0
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler.cjs +768 -34
|
@@ -33,7 +33,7 @@ const { execFileSync } = require('node:child_process');
|
|
|
33
33
|
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'looks-done-test-'));
|
|
34
34
|
process.env.HOME = tmpHome;
|
|
35
35
|
|
|
36
|
-
const { reverifyNeedsReview, computeLooksDone } = require('../scheduler.cjs');
|
|
36
|
+
const { reverifyNeedsReview, computeLooksDone, findSatisfyingCommitOnMain } = require('../scheduler.cjs');
|
|
37
37
|
const { resolvePrdWriteDir } = require('../lib/prdLocations.cjs');
|
|
38
38
|
const { bustCwdCache } = require('../lib/queueStore.cjs');
|
|
39
39
|
|
|
@@ -51,6 +51,18 @@ function initRepo(dir) {
|
|
|
51
51
|
git(['commit', '-q', '-m', 'initial'], dir);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// -b main pins the branch name regardless of the host's init.defaultBranch
|
|
55
|
+
// config — findSatisfyingCommitOnMain's tests need a deterministic 'main' ref.
|
|
56
|
+
function initRepoOnMain(dir) {
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
git(['init', '-q', '-b', 'main'], dir);
|
|
59
|
+
git(['config', 'user.email', 'test@example.com'], dir);
|
|
60
|
+
git(['config', 'user.name', 'Test'], dir);
|
|
61
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'hello\n', 'utf8');
|
|
62
|
+
git(['add', '-A'], dir);
|
|
63
|
+
git(['commit', '-q', '-m', 'initial'], dir);
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
function commitFile(dir, relPath, content, message) {
|
|
55
67
|
const abs = path.join(dir, relPath);
|
|
56
68
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
@@ -228,6 +240,134 @@ test('computeLooksDone: no declared paths on the PRD → null, never fabricates
|
|
|
228
240
|
assert.equal(looksDone, null);
|
|
229
241
|
});
|
|
230
242
|
|
|
243
|
+
test('needs_review row with autoFixAttempted:true is never also stamped looksDone (PRD 1136 — mutually exclusive)', async () => {
|
|
244
|
+
const projectCwd = path.join(tmpHome, 'proj-already-fix-planned');
|
|
245
|
+
initRepo(projectCwd);
|
|
246
|
+
registerActiveProject(projectCwd, 'proj-already-fix-planned-slug');
|
|
247
|
+
writePrd(projectCwd, '30-example', [
|
|
248
|
+
'# Implementation notes',
|
|
249
|
+
'Edit `src/thing.js`.',
|
|
250
|
+
].join('\n'));
|
|
251
|
+
|
|
252
|
+
// startedAt/finishedAt are fixed well in the past so the real-time commit
|
|
253
|
+
// below lands outside committedInWindow's [startedAt, finishedAt+60s]
|
|
254
|
+
// window — otherwise the pre-existing heal pass would auto-complete the
|
|
255
|
+
// row before ever reaching the stillOpen/looksDone branch this test
|
|
256
|
+
// targets (committedInWindow, unlike computeLooksDone's landedSinceRun,
|
|
257
|
+
// has no upper bound skip).
|
|
258
|
+
const startedAt = '2020-01-01T00:00:00.000Z';
|
|
259
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
260
|
+
{
|
|
261
|
+
slug: '30-example',
|
|
262
|
+
status: 'needs_review',
|
|
263
|
+
cwd: projectCwd,
|
|
264
|
+
runId: 'run-30',
|
|
265
|
+
startedAt,
|
|
266
|
+
finishedAt: '2020-01-01T00:05:00.000Z',
|
|
267
|
+
verifierVerdict: 'transcript_errors',
|
|
268
|
+
error: 'transcript_errors: stale verdict',
|
|
269
|
+
// A fix-plan child was already minted for this row at finalize time —
|
|
270
|
+
// the exact ordering that always precedes a later reverifyNeedsReview
|
|
271
|
+
// pass, since autoFixAttempted is stamped synchronously in the same
|
|
272
|
+
// mutate() as the needs_review transition itself.
|
|
273
|
+
autoFixAttempted: true,
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
writeRunLog('run-30', '30-example', [
|
|
277
|
+
'[scheduler] starting 30-example',
|
|
278
|
+
JSON.stringify({ type: 'result', subtype: 'error_during_execution', is_error: true, result: 'still broken' }),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
commitFile(projectCwd, 'src/thing.js', 'hello', 'later commit touching the declared path');
|
|
282
|
+
|
|
283
|
+
await reverifyNeedsReview();
|
|
284
|
+
|
|
285
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
286
|
+
assert.equal(jobs.length, 1);
|
|
287
|
+
assert.equal(jobs[0].status, 'needs_review');
|
|
288
|
+
assert.equal(jobs[0].looksDone, undefined, 'a row already fix-planned must never also be stamped looksDone');
|
|
289
|
+
assert.equal(jobs[0].error, 'transcript_errors: stale verdict', 'the looksDone annotation must not overwrite the original error');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// findSatisfyingCommitOnMain — PRD 1136's already-satisfied-on-main evidence
|
|
293
|
+
// query. Requires EVERY declared path to have landed on main since
|
|
294
|
+
// queuedAt, not just one — a code-review finding (2026-09-07) confirmed
|
|
295
|
+
// that a PRD citing both its real target file and a second file purely as
|
|
296
|
+
// context (e.g. a `path/to/file.js:123` reference) would otherwise
|
|
297
|
+
// auto-complete unattended off any unrelated commit touching only that
|
|
298
|
+
// second, incidental file.
|
|
299
|
+
|
|
300
|
+
test('findSatisfyingCommitOnMain: single declared path, satisfying commit lands on main → returns the sha', async () => {
|
|
301
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-single-path');
|
|
302
|
+
initRepoOnMain(projectCwd);
|
|
303
|
+
registerActiveProject(projectCwd, 'proj-satisfied-single-path-slug');
|
|
304
|
+
writePrd(projectCwd, '40-single-path', [
|
|
305
|
+
'# Implementation notes',
|
|
306
|
+
'Edit `src/real-work.js`.',
|
|
307
|
+
].join('\n'));
|
|
308
|
+
|
|
309
|
+
const queuedAt = new Date().toISOString();
|
|
310
|
+
await wait(1100);
|
|
311
|
+
commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
|
|
312
|
+
|
|
313
|
+
const job = { slug: '40-single-path', cwd: projectCwd, queuedAt };
|
|
314
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
315
|
+
assert.equal(commits.length, 1);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('findSatisfyingCommitOnMain: two declared paths but only ONE lands → [] (not satisfied — no false completion off an incidental context citation)', async () => {
|
|
319
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-partial-path');
|
|
320
|
+
initRepoOnMain(projectCwd);
|
|
321
|
+
registerActiveProject(projectCwd, 'proj-satisfied-partial-path-slug');
|
|
322
|
+
writePrd(projectCwd, '41-partial-path', [
|
|
323
|
+
'# Implementation notes',
|
|
324
|
+
'Edit `src/real-work.js`. See `src/main/hotfile.js:123` for context.',
|
|
325
|
+
].join('\n'));
|
|
326
|
+
|
|
327
|
+
const queuedAt = new Date().toISOString();
|
|
328
|
+
await wait(1100);
|
|
329
|
+
// Only the incidentally-cited context file gets a later, UNRELATED commit
|
|
330
|
+
// — the actual target path (src/real-work.js) never lands.
|
|
331
|
+
commitFile(projectCwd, 'src/main/hotfile.js', 'unrelated change', 'unrelated commit');
|
|
332
|
+
|
|
333
|
+
const job = { slug: '41-partial-path', cwd: projectCwd, queuedAt };
|
|
334
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
335
|
+
assert.deepEqual(commits, [], 'partial path coverage must never be treated as satisfied');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('findSatisfyingCommitOnMain: two declared paths, BOTH land → returns satisfying commits', async () => {
|
|
339
|
+
const projectCwd = path.join(tmpHome, 'proj-satisfied-both-paths');
|
|
340
|
+
initRepoOnMain(projectCwd);
|
|
341
|
+
registerActiveProject(projectCwd, 'proj-satisfied-both-paths-slug');
|
|
342
|
+
writePrd(projectCwd, '42-both-paths', [
|
|
343
|
+
'# Implementation notes',
|
|
344
|
+
'Edit `src/real-work.js` and `src/main/hotfile.js`.',
|
|
345
|
+
].join('\n'));
|
|
346
|
+
|
|
347
|
+
const queuedAt = new Date().toISOString();
|
|
348
|
+
await wait(1100);
|
|
349
|
+
commitFile(projectCwd, 'src/real-work.js', 'hello', 'fix real-work');
|
|
350
|
+
commitFile(projectCwd, 'src/main/hotfile.js', 'hello', 'fix hotfile too');
|
|
351
|
+
|
|
352
|
+
const job = { slug: '42-both-paths', cwd: projectCwd, queuedAt };
|
|
353
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
354
|
+
assert.ok(commits.length >= 1, 'full path coverage must be treated as satisfied');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('findSatisfyingCommitOnMain: no queuedAt on the job → [] without querying git', async () => {
|
|
358
|
+
const projectCwd = path.join(tmpHome, 'proj-no-queued-at');
|
|
359
|
+
initRepoOnMain(projectCwd);
|
|
360
|
+
registerActiveProject(projectCwd, 'proj-no-queued-at-slug');
|
|
361
|
+
writePrd(projectCwd, '43-no-queued-at', [
|
|
362
|
+
'# Implementation notes',
|
|
363
|
+
'Edit `src/thing.js`.',
|
|
364
|
+
].join('\n'));
|
|
365
|
+
|
|
366
|
+
const job = { slug: '43-no-queued-at', cwd: projectCwd };
|
|
367
|
+
const commits = await findSatisfyingCommitOnMain(job);
|
|
368
|
+
assert.deepEqual(commits, []);
|
|
369
|
+
});
|
|
370
|
+
|
|
231
371
|
test('computeLooksDone: non-git cwd → null without throwing (git-unavailable)', async () => {
|
|
232
372
|
const notARepo = fs.mkdtempSync(path.join(tmpHome, 'not-a-repo-'));
|
|
233
373
|
writePrd(notARepo, '11-no-repo', [
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-periodic-reverify-guard.test.cjs
|
|
3
|
+
*
|
|
4
|
+
* The 10-minute periodic tick cheap-guards reverifyNeedsReview() so the log
|
|
5
|
+
* scan only runs when the queue actually holds something rescannable. That
|
|
6
|
+
* guard was hand-written as `jobs.some(j => j.status === 'needs_review')`
|
|
7
|
+
* while PRD 1102 widened isRescanCandidate to also accept an unverified-
|
|
8
|
+
* shaped `failed` row — so reverifyNeedsReview's failed → needs_review
|
|
9
|
+
* looksDone branch was unreachable from the periodic path on any queue with
|
|
10
|
+
* zero needs_review rows. A failed row with no run log stayed wedged for five
|
|
11
|
+
* days (reported 2026-09-10 from social-signals-trader); only an app restart,
|
|
12
|
+
* via the unguarded boot call, could reach it.
|
|
13
|
+
*
|
|
14
|
+
* These tests pin the guard to isRescanCandidate so the two cannot drift again.
|
|
15
|
+
*
|
|
16
|
+
* HOME is overridden to a tmp dir BEFORE requiring scheduler.cjs — see
|
|
17
|
+
* scheduler-reap-dead-running-jobs.test.cjs's comment for why.
|
|
18
|
+
*
|
|
19
|
+
* Run: timeout 180 npx vitest run src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const assert = require('node:assert/strict');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reverify-guard-test-'));
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
|
|
32
|
+
const { shouldRunPeriodicReverify, isRescanCandidate } = require('../scheduler.cjs');
|
|
33
|
+
|
|
34
|
+
function writeRunLog(runId, slug, lines) {
|
|
35
|
+
const runDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
36
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), lines.join('\n') + '\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('a lone unverified-shaped failed row (no needs_review sibling) fires the periodic pass', () => {
|
|
41
|
+
writeRunLog('run-guard-1', '4056-outcome-stats', ['[scheduler] starting 4056-outcome-stats']); // no result event
|
|
42
|
+
const jobs = [
|
|
43
|
+
{ slug: '4059-chores-status', status: 'running', runId: 'run-guard-running' },
|
|
44
|
+
{
|
|
45
|
+
slug: '4056-outcome-stats',
|
|
46
|
+
status: 'failed',
|
|
47
|
+
runId: 'run-guard-1',
|
|
48
|
+
error: 'reaped: no runtime.pid recorded after 10m — spawn never completed (outcome=no_result)',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
assert.equal(isRescanCandidate(jobs[1]), true, 'fixture must be a genuine rescan candidate');
|
|
52
|
+
assert.equal(shouldRunPeriodicReverify(jobs), true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a failed row with a real result event (genuine red gate) does NOT fire the pass', () => {
|
|
56
|
+
writeRunLog('run-guard-2', '200-red', [
|
|
57
|
+
'[scheduler] starting 200-red',
|
|
58
|
+
JSON.stringify({ type: 'result', subtype: 'success', is_error: true }),
|
|
59
|
+
]);
|
|
60
|
+
const jobs = [{ slug: '200-red', status: 'failed', runId: 'run-guard-2' }];
|
|
61
|
+
assert.equal(isRescanCandidate(jobs[0]), false);
|
|
62
|
+
assert.equal(shouldRunPeriodicReverify(jobs), false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('needs_review with a rescannable verdict still fires; a non-rescannable verdict does not', () => {
|
|
66
|
+
writeRunLog('run-guard-3', '300-nr', ['[scheduler] starting 300-nr']);
|
|
67
|
+
assert.equal(
|
|
68
|
+
shouldRunPeriodicReverify([
|
|
69
|
+
{ slug: '300-nr', status: 'needs_review', runId: 'run-guard-3', verifierVerdict: 'verify_unavailable' },
|
|
70
|
+
]),
|
|
71
|
+
true,
|
|
72
|
+
);
|
|
73
|
+
assert.equal(
|
|
74
|
+
shouldRunPeriodicReverify([
|
|
75
|
+
{ slug: '300-nr', status: 'needs_review', runId: 'run-guard-3', verifierVerdict: 'uncommitted_changes' },
|
|
76
|
+
]),
|
|
77
|
+
false,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a queue with nothing rescannable, or a non-array, does not fire the pass', () => {
|
|
82
|
+
assert.equal(shouldRunPeriodicReverify([{ slug: 'a', status: 'pending' }, { slug: 'b', status: 'completed' }]), false);
|
|
83
|
+
assert.equal(shouldRunPeriodicReverify([]), false);
|
|
84
|
+
assert.equal(shouldRunPeriodicReverify(undefined), false);
|
|
85
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-rate-limit-pause.test.cjs — PRD 1118: pause against the BINDING
|
|
3
|
+
* rate-limit window read off a run's own log, not refreshNextReset()'s
|
|
4
|
+
* five_hour-only assumption, and never leave an indefinite pause with no
|
|
5
|
+
* resume timer when both the log and the billing endpoint come up empty
|
|
6
|
+
* (the live failure mode: the usage endpoint itself 429s).
|
|
7
|
+
*
|
|
8
|
+
* These test the PURE decision functions scheduler.cjs's setPaused() and the
|
|
9
|
+
* spawnJob() rateLimited branch delegate to — never the real setPaused/mutate
|
|
10
|
+
* path, which writes the machine's live scheduler queue.json at a fixed
|
|
11
|
+
* homedir path (~/.claude/session-manager/scheduled-plans) and must not be
|
|
12
|
+
* touched by a test run.
|
|
13
|
+
*
|
|
14
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-rate-limit-pause.test.cjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
import { test } from 'vitest';
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const {
|
|
23
|
+
resolveRateLimitPauseReset,
|
|
24
|
+
computeEffectiveResumeAt,
|
|
25
|
+
computeResumeDelay,
|
|
26
|
+
} = require('../scheduler.cjs');
|
|
27
|
+
|
|
28
|
+
// Repo-tracked, byte-for-byte copy of the real 2026-09-05 starry-night-ships
|
|
29
|
+
// incident log (see src/main/lib/__tests__/rateLimitWindow.test.cjs for the
|
|
30
|
+
// live-path parity check) — kept hermetic rather than pointed at the
|
|
31
|
+
// machine-local live path directly.
|
|
32
|
+
const FIXTURE = path.join(__dirname, '..', 'lib', '__tests__', 'fixtures', '204-mercury-steam-horse.log.txt');
|
|
33
|
+
const FIVE_HOUR_ISO = new Date(1788643800 * 1000).toISOString(); // what refreshNextReset() would have returned
|
|
34
|
+
const SEVEN_DAY_ISO = new Date(1789059600 * 1000).toISOString(); // the actually-binding window
|
|
35
|
+
|
|
36
|
+
test('resolveRateLimitPauseReset: prefers the log-derived seven-day reset over the billing five_hour reset', () => {
|
|
37
|
+
const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO);
|
|
38
|
+
assert.strictEqual(resetIso, SEVEN_DAY_ISO);
|
|
39
|
+
assert.notStrictEqual(resetIso, FIVE_HOUR_ISO);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('resolveRateLimitPauseReset: falls back to the billing reset when the log yields none', () => {
|
|
43
|
+
const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO);
|
|
44
|
+
assert.strictEqual(resetIso, FIVE_HOUR_ISO);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('resolveRateLimitPauseReset: null when BOTH the log and the billing endpoint are unavailable', () => {
|
|
48
|
+
assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null), null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('computeEffectiveResumeAt: rate_limit with no resumeAt gets the same bounded 30-minute fallback as network', () => {
|
|
52
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
53
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', null, now);
|
|
54
|
+
assert.strictEqual(resumeAt, new Date(now + 30 * 60_000).toISOString());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('computeEffectiveResumeAt: an explicit resumeAt (the log-derived reset) is never overridden', () => {
|
|
58
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, Date.now());
|
|
59
|
+
assert.strictEqual(resumeAt, SEVEN_DAY_ISO);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('computeEffectiveResumeAt: other reasons (e.g. auth) get no fallback — stays indefinite', () => {
|
|
63
|
+
assert.strictEqual(computeEffectiveResumeAt('auth', null, Date.now()), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('computeResumeDelay: a seven-day-out reset (~5 days from now) is armed, not treated as too-far', () => {
|
|
67
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
68
|
+
// ~5 days out — comfortably under the 0x7fffffff (~24.8 day) setTimeout cap.
|
|
69
|
+
const fiveDaysOut = new Date(now + 5 * 24 * 60 * 60_000).toISOString();
|
|
70
|
+
const { delayMs, tooFar } = computeResumeDelay(fiveDaysOut, now);
|
|
71
|
+
assert.strictEqual(tooFar, false);
|
|
72
|
+
assert.ok(delayMs < 0x7fffffff, `expected delay under setTimeout cap, got ${delayMs}`);
|
|
73
|
+
assert.ok(delayMs > 400_000_000, `expected ~5 days in ms, got ${delayMs}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('computeResumeDelay: a reset far enough out DOES trip the overflow guard', () => {
|
|
77
|
+
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
78
|
+
const thirtyDaysOut = new Date(now + 30 * 24 * 60 * 60_000).toISOString();
|
|
79
|
+
const { tooFar } = computeResumeDelay(thirtyDaysOut, now);
|
|
80
|
+
assert.strictEqual(tooFar, true);
|
|
81
|
+
});
|
|
@@ -223,7 +223,11 @@ test('reapDeadRunningJobs salvages a delta-scoped in-place patch (PRD 1098) when
|
|
|
223
223
|
|
|
224
224
|
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
225
225
|
const row = jobs.find((j) => j.slug === 'vanished-inplace');
|
|
226
|
-
|
|
226
|
+
// PRD 1133: a successful-looking reap that left everything uncommitted
|
|
227
|
+
// (no branch, HEAD never moved) must never read as 'completed' — this is
|
|
228
|
+
// exactly the dangerous shape the reaper now proves against.
|
|
229
|
+
assert.equal(row.status, 'needs_review');
|
|
230
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
227
231
|
assert.ok(row.salvagePatch, 'a salvage patch must be recorded on the row');
|
|
228
232
|
assert.ok(fs.existsSync(row.salvagePatch));
|
|
229
233
|
const patch = fs.readFileSync(row.salvagePatch, 'utf8');
|
|
@@ -332,8 +336,207 @@ test('reapDeadRunningJobs skips in-place salvage (no whole-tree dump) when the r
|
|
|
332
336
|
|
|
333
337
|
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
334
338
|
const row = jobs.find((j) => j.slug === 'vanished-no-baseline');
|
|
335
|
-
|
|
339
|
+
// PRD 1133: no commit landed (HEAD never moved) — must not read as 'completed'
|
|
340
|
+
// just because the log's own result event looked clean.
|
|
341
|
+
assert.equal(row.status, 'needs_review');
|
|
342
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
336
343
|
assert.equal(row.salvagePatch, undefined, 'must skip salvage entirely rather than ever dumping the whole tree');
|
|
337
344
|
assert.equal(row.leftoverPaths, undefined, 'no baseline means no safe attribution — must not guess');
|
|
338
345
|
assert.equal(row.leftoverCount, undefined);
|
|
339
346
|
});
|
|
347
|
+
|
|
348
|
+
// ---------- PRD 1133: reaper must prove integration before 'completed' ----------
|
|
349
|
+
|
|
350
|
+
test('reapDeadRunningJobs: a worktree job shaped like PRD 1118 (branch un-integrated, landedCommit absent) comes out needs_review, naming the branch, and never merges it', async () => {
|
|
351
|
+
const projectCwd = path.join(tmpHome, 'i-project-1118-shape');
|
|
352
|
+
initRepo(projectCwd);
|
|
353
|
+
registerActiveProject(projectCwd);
|
|
354
|
+
|
|
355
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
356
|
+
const slug = '1118-pause-until-the-binding-rate-limit-window';
|
|
357
|
+
const branch = `sm-job/${slug}`;
|
|
358
|
+
// Simulate the job's own worktree branch: one real commit, never merged
|
|
359
|
+
// back into the main tree (exactly the live 1118 shape — "one real commit,
|
|
360
|
+
// +301", stranded on sm-job/1118-... with landedCommit undefined).
|
|
361
|
+
git(['checkout', '-b', branch], projectCwd);
|
|
362
|
+
fs.writeFileSync(path.join(projectCwd, 'rateLimitWindow.cjs'), 'module.exports = {};\n', 'utf8');
|
|
363
|
+
git(['add', '-A'], projectCwd);
|
|
364
|
+
git(['commit', '-q', '-m', 'add rateLimitWindow.cjs'], projectCwd);
|
|
365
|
+
// Back to the original tip (detached is fine — reap only reads guardCwd's
|
|
366
|
+
// current HEAD, it never needs a named branch checked out).
|
|
367
|
+
git(['checkout', headBefore], projectCwd);
|
|
368
|
+
|
|
369
|
+
const runId = `run-${slug}`;
|
|
370
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
371
|
+
{
|
|
372
|
+
slug,
|
|
373
|
+
status: 'running',
|
|
374
|
+
cwd: projectCwd,
|
|
375
|
+
runId,
|
|
376
|
+
runtime: { pid: 999999 }, // guaranteed-dead pid
|
|
377
|
+
guardHeadBefore: headBefore,
|
|
378
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
379
|
+
},
|
|
380
|
+
]);
|
|
381
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
382
|
+
|
|
383
|
+
bustCwdCache();
|
|
384
|
+
await reapDeadRunningJobs();
|
|
385
|
+
|
|
386
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
387
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
388
|
+
assert.equal(row.status, 'needs_review', 'a successful-looking result event must not be enough on its own');
|
|
389
|
+
assert.equal(row.verifierVerdict, 'reaped_without_integration');
|
|
390
|
+
assert.match(row.error, new RegExp(branch.replace('/', '\\/')), 'the parked reason must name the branch that still holds the work');
|
|
391
|
+
assert.equal(row.landedCommit, undefined, 'nothing was actually merged, so landedCommit must stay unset');
|
|
392
|
+
|
|
393
|
+
// The branch itself must survive untouched — this reap must never attempt
|
|
394
|
+
// (let alone perform) the merge.
|
|
395
|
+
const branchStillExists = git(['rev-parse', '--verify', branch], projectCwd).trim();
|
|
396
|
+
assert.ok(branchStillExists, 'the un-integrated branch must be preserved, not merged or deleted, by a mere reap');
|
|
397
|
+
assert.equal(git(['rev-parse', 'HEAD'], projectCwd).trim(), headBefore, 'guardCwd HEAD must not have moved — no merge was performed');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('reapDeadRunningJobs: a worktree job whose branch is already fully integrated into HEAD comes out completed, with landedCommit stamped', async () => {
|
|
401
|
+
const projectCwd = path.join(tmpHome, 'j-project-real-success');
|
|
402
|
+
initRepo(projectCwd);
|
|
403
|
+
registerActiveProject(projectCwd);
|
|
404
|
+
|
|
405
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
406
|
+
const slug = 'real-successful-worktree-run';
|
|
407
|
+
const branch = `sm-job/${slug}`;
|
|
408
|
+
git(['checkout', '-b', branch], projectCwd);
|
|
409
|
+
fs.writeFileSync(path.join(projectCwd, 'feature.txt'), 'shipped\n', 'utf8');
|
|
410
|
+
git(['add', '-A'], projectCwd);
|
|
411
|
+
git(['commit', '-q', '-m', 'ship feature'], projectCwd);
|
|
412
|
+
git(['checkout', headBefore], projectCwd); // back to the pre-branch tip (detached)
|
|
413
|
+
// Simulate a normal completion pass having already integrated the branch
|
|
414
|
+
// (integrateJobBranch's own fast-forward path) BEFORE the process vanished
|
|
415
|
+
// between that merge and the queue write landing.
|
|
416
|
+
git(['merge', '--ff-only', branch], projectCwd);
|
|
417
|
+
const headAfterMerge = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
418
|
+
|
|
419
|
+
const runId = `run-${slug}`;
|
|
420
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
421
|
+
{
|
|
422
|
+
slug,
|
|
423
|
+
status: 'running',
|
|
424
|
+
cwd: projectCwd,
|
|
425
|
+
runId,
|
|
426
|
+
runtime: { pid: 999999 },
|
|
427
|
+
guardHeadBefore: headBefore,
|
|
428
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
429
|
+
},
|
|
430
|
+
]);
|
|
431
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
432
|
+
|
|
433
|
+
bustCwdCache();
|
|
434
|
+
await reapDeadRunningJobs();
|
|
435
|
+
|
|
436
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
437
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
438
|
+
assert.equal(row.status, 'completed', 'the branch is already an ancestor of HEAD — the work genuinely landed');
|
|
439
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
440
|
+
assert.equal(row.landedCommit, headAfterMerge, 'HEAD advanced during the run window — landedCommit must be stamped');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('reapDeadRunningJobs: a worktree job whose branch never diverged from its base (legitimate no-op) still completes, not punished', async () => {
|
|
444
|
+
const projectCwd = path.join(tmpHome, 'k-project-noop-worktree');
|
|
445
|
+
initRepo(projectCwd);
|
|
446
|
+
registerActiveProject(projectCwd);
|
|
447
|
+
|
|
448
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
449
|
+
const slug = 'noop-worktree-job';
|
|
450
|
+
const branch = `sm-job/${slug}`;
|
|
451
|
+
// Branch created but nothing ever committed on it — a genuine no-op run.
|
|
452
|
+
git(['branch', branch], projectCwd);
|
|
453
|
+
|
|
454
|
+
const runId = `run-${slug}`;
|
|
455
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
456
|
+
{
|
|
457
|
+
slug,
|
|
458
|
+
status: 'running',
|
|
459
|
+
cwd: projectCwd,
|
|
460
|
+
runId,
|
|
461
|
+
runtime: { pid: 999999 },
|
|
462
|
+
guardHeadBefore: headBefore,
|
|
463
|
+
startedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
464
|
+
},
|
|
465
|
+
]);
|
|
466
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
467
|
+
|
|
468
|
+
bustCwdCache();
|
|
469
|
+
await reapDeadRunningJobs();
|
|
470
|
+
|
|
471
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
472
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
473
|
+
assert.equal(row.status, 'completed', 'a branch with no new commits is a legitimate no-op, not a finish-protocol violation');
|
|
474
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
475
|
+
assert.equal(row.landedCommit, undefined, 'HEAD never moved — nothing to stamp');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('reapDeadRunningJobs: an in-place run in a git repo whose HEAD genuinely advanced during the run window completes, with landedCommit stamped', async () => {
|
|
479
|
+
const projectCwd = path.join(tmpHome, 'l-project-inplace-success');
|
|
480
|
+
initRepo(projectCwd);
|
|
481
|
+
registerActiveProject(projectCwd);
|
|
482
|
+
|
|
483
|
+
const headBefore = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
484
|
+
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
|
485
|
+
fs.writeFileSync(path.join(projectCwd, 'shipped.txt'), 'done\n', 'utf8');
|
|
486
|
+
git(['add', '-A'], projectCwd);
|
|
487
|
+
git(['commit', '-q', '-m', 'in-place job commit'], projectCwd);
|
|
488
|
+
const headAfter = git(['rev-parse', 'HEAD'], projectCwd).trim();
|
|
489
|
+
|
|
490
|
+
const slug = 'inplace-job-real-commit';
|
|
491
|
+
const runId = `run-${slug}`;
|
|
492
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
493
|
+
{
|
|
494
|
+
slug,
|
|
495
|
+
status: 'running',
|
|
496
|
+
cwd: projectCwd,
|
|
497
|
+
runId,
|
|
498
|
+
runtime: { pid: 999999 },
|
|
499
|
+
guardHeadBefore: headBefore,
|
|
500
|
+
startedAt,
|
|
501
|
+
},
|
|
502
|
+
]);
|
|
503
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
504
|
+
|
|
505
|
+
bustCwdCache();
|
|
506
|
+
await reapDeadRunningJobs();
|
|
507
|
+
|
|
508
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
509
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
510
|
+
assert.equal(row.status, 'completed');
|
|
511
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
512
|
+
assert.equal(row.landedCommit, headAfter);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('reapDeadRunningJobs: a non-git cwd skips the integration check entirely, preserving today\'s behaviour', async () => {
|
|
516
|
+
const projectCwd = path.join(tmpHome, 'm-project-non-git');
|
|
517
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
518
|
+
registerActiveProject(projectCwd);
|
|
519
|
+
|
|
520
|
+
const slug = 'non-git-cwd-job';
|
|
521
|
+
const runId = `run-${slug}`;
|
|
522
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
523
|
+
{
|
|
524
|
+
slug,
|
|
525
|
+
status: 'running',
|
|
526
|
+
cwd: projectCwd,
|
|
527
|
+
runId,
|
|
528
|
+
runtime: { pid: 999999 },
|
|
529
|
+
// No git repo at all — the integration/HEAD-advance check must be
|
|
530
|
+
// skipped, never treated as a failure to prove landing.
|
|
531
|
+
},
|
|
532
|
+
]);
|
|
533
|
+
writeRunLog(runId, slug, ['{"type":"result","subtype":"success","result":"done","is_error":false}']);
|
|
534
|
+
|
|
535
|
+
bustCwdCache();
|
|
536
|
+
await reapDeadRunningJobs();
|
|
537
|
+
|
|
538
|
+
const jobs = JSON.parse(fs.readFileSync(queuePath, 'utf8')).jobs;
|
|
539
|
+
const row = jobs.find((j) => j.slug === slug);
|
|
540
|
+
assert.equal(row.status, 'completed', 'a non-git cwd must keep today\'s behaviour — the check is meaningless there');
|
|
541
|
+
assert.equal(row.verifierVerdict, undefined);
|
|
542
|
+
});
|