claude-code-session-manager 0.76.0 → 0.77.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-CBx9l4zN.js → AgentLibrary-B2ie8bbw.js} +2 -2
- package/dist/assets/{DataModel-Bf0EIE_t.js → DataModel-BIJPYw32.js} +1 -1
- package/dist/assets/{History-CpdtWhC8.js → History-CeY6dk9S.js} +2 -2
- package/dist/assets/{Hooks-DyUbMDmg.js → Hooks-BFH2ocKg.js} +2 -2
- package/dist/assets/{HostBilko-By-wIpry.js → HostBilko-36gj9wLz.js} +1 -1
- package/dist/assets/{Library-CQmo4QVC.js → Library-C-hBct39.js} +1 -1
- package/dist/assets/{ListDetail-BQMd6NOm.js → ListDetail-CNq64VWV.js} +1 -1
- package/dist/assets/{MarkdownEditor-DEp43FXX.js → MarkdownEditor-Bh3qt5-1.js} +1 -1
- package/dist/assets/{McpServers-CLarzwqA.js → McpServers-DpGN0oyz.js} +1 -1
- package/dist/assets/{Memory-B0sCdIy1.js → Memory-D59hUjC4.js} +6 -6
- package/dist/assets/{Panel-BhWPVOCD.js → Panel-DCgbaoci.js} +1 -1
- package/dist/assets/{Permissions-Ddlq8T_O.js → Permissions-DAmQ0DYV.js} +2 -2
- package/dist/assets/{Plugins-D2oA_2Jl.js → Plugins-Dyfgn6Is.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DgAgavUM.js → ProvenanceBadge-BiYhPO1U.js} +1 -1
- package/dist/assets/SaveBar-RV7B6sOh.js +1 -0
- package/dist/assets/Scheduler-BPaNqx1b.js +14 -0
- package/dist/assets/{ScopeSwitcher-C_zWEtIl.js → ScopeSwitcher-P4mdLGNU.js} +1 -1
- package/dist/assets/{Settings-2Vx3X5SI.js → Settings-BL4vf5aX.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-BDEUjlTQ.js → SkillReferenceGraph-BRBDyi1_.js} +1 -1
- package/dist/assets/{Skills-Cmrz_LeN.js → Skills-BV08gDUH.js} +2 -2
- package/dist/assets/{SystemPrompt-DVA1eYDP.js → SystemPrompt-CLftSsDw.js} +1 -1
- package/dist/assets/TagLibrary-Bp8jGsd5.js +1 -0
- package/dist/assets/{TiptapBody-DmPc3amD.js → TiptapBody-jCpuB6E5.js} +1 -1
- package/dist/assets/{Toggle-zfd5LJkK.js → Toggle-D2paA1xf.js} +1 -1
- package/dist/assets/{index-B_4PNh9T.js → index-BDRSqBl3.js} +175 -175
- package/dist/assets/{index-DIjnPkRN.css → index-CYhdtisq.css} +1 -1
- package/dist/assets/{settingsSchema-B9es6fdA.js → settingsSchema-6IOLjZZN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -2
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/scripts/project-pages-logic/dist/logic.cjs +4709 -0
- package/scripts/render-project-pages/dist/renderer.cjs +18900 -0
- package/scripts/render-project-pages.cjs +70 -0
- package/scripts/scheduler-mcp-server.cjs +115 -1
- package/scripts/validate-project-pages-summary.cjs +62 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +66 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/prdAgentType.test.cjs +103 -0
- package/src/main/__tests__/prdCreate.test.cjs +138 -0
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +117 -0
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +108 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +485 -0
- package/src/main/__tests__/projectPages.test.cjs +73 -1
- package/src/main/__tests__/rcaReport.test.cjs +54 -0
- package/src/main/__tests__/runVerify.test.cjs +94 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +43 -0
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +103 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +10 -0
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +78 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +242 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +31 -0
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +201 -0
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +52 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +241 -0
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +135 -0
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +222 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +147 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +212 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +194 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +75 -14
- package/src/main/config.cjs +4 -1
- package/src/main/index.cjs +8 -1
- package/src/main/ipcSchemas.cjs +51 -0
- package/src/main/lib/__tests__/childWithLog.test.cjs +78 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +152 -2
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +4 -2
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +40 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +277 -4
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +153 -0
- package/src/main/lib/__tests__/jobWorktree.test.cjs +5 -3
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +73 -0
- package/src/main/lib/__tests__/launchFailure.test.cjs +220 -0
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +1 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +7 -0
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +82 -0
- package/src/main/lib/__tests__/queueHealth.test.cjs +58 -0
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +39 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +22 -1
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +125 -0
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +109 -0
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +71 -0
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +350 -0
- package/src/main/lib/agentModelResolve.cjs +58 -0
- package/src/main/lib/childWithLog.cjs +40 -5
- package/src/main/lib/claudeBin.cjs +54 -1
- package/src/main/lib/definitionOfDone.cjs +3 -2
- package/src/main/lib/delegationReadiness.cjs +115 -9
- package/src/main/lib/epicWorktreeMint.cjs +5 -2
- package/src/main/lib/fixChainDepth.cjs +45 -0
- package/src/main/lib/gitWorktree.cjs +464 -19
- package/src/main/lib/jobWorktree.cjs +1 -0
- package/src/main/lib/landedSinceRun.cjs +55 -0
- package/src/main/lib/launchFailure.cjs +357 -0
- package/src/main/lib/mcpToolCatalog.cjs +87 -2
- package/src/main/lib/opsOwnership.cjs +12 -0
- package/src/main/lib/prdAgentType.cjs +84 -0
- package/src/main/lib/prdCreate.cjs +57 -1
- package/src/main/lib/prdDeclaredPaths.cjs +70 -0
- package/src/main/lib/prdFrontmatter.cjs +17 -3
- package/src/main/lib/projectHomeAdminRoutes.cjs +402 -0
- package/src/main/lib/projectPageSummarySchema.cjs +181 -0
- package/src/main/lib/queueHealth.cjs +38 -0
- package/src/main/lib/queueStore.cjs +9 -2
- package/src/main/lib/quietMachineLease.cjs +48 -0
- package/src/main/lib/rcaReport.cjs +53 -3
- package/src/main/lib/reaperHelpers.cjs +18 -1
- package/src/main/lib/scheduleJobSchema.cjs +31 -0
- package/src/main/lib/scheduleJobTransitions.cjs +6 -2
- package/src/main/lib/schedulerBatch.cjs +133 -29
- package/src/main/lib/schedulerConfig.cjs +19 -0
- package/src/main/projectPages.cjs +160 -2
- package/src/main/runVerify.cjs +50 -9
- package/src/main/scheduler/prdParser.cjs +18 -1
- package/src/main/scheduler.cjs +1371 -97
- package/src/main/seedAgentPersonas.cjs +62 -21
- package/src/main/templates/project-pages-catalog.json +741 -0
- package/src/main/templates/project-pages-pipeline.md +417 -0
- package/src/preload/api.d.ts +118 -2
- package/src/preload/index.cjs +7 -0
- package/src/seed/agents/project-home-builder.md +59 -0
- package/dist/assets/SaveBar-Qvc4Ek-H.js +0 -1
- package/dist/assets/Scheduler-BmYJvNzK.js +0 -14
- package/dist/assets/TagLibrary-DYJGAKZu.js +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 60 npx vitest run src/main/lib/__tests__/quietMachineLease.test.cjs
|
|
4
|
+
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const lease = require('../quietMachineLease.cjs');
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
lease.__resetForTests();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('free lease: isHeld false, acquire succeeds and reports the holder', () => {
|
|
13
|
+
assert.equal(lease.isHeld(), false);
|
|
14
|
+
assert.equal(lease.acquire('101-quiet'), true);
|
|
15
|
+
assert.equal(lease.isHeld(), true);
|
|
16
|
+
assert.equal(lease.holder(), '101-quiet');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a second acquire while held fails — exclusive by construction', () => {
|
|
20
|
+
assert.equal(lease.acquire('101-quiet'), true);
|
|
21
|
+
assert.equal(lease.acquire('102-other'), false);
|
|
22
|
+
assert.equal(lease.holder(), '101-quiet');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('release frees the lease so the next acquire can succeed', () => {
|
|
26
|
+
lease.acquire('101-quiet');
|
|
27
|
+
assert.equal(lease.release('101-quiet'), true);
|
|
28
|
+
assert.equal(lease.isHeld(), false);
|
|
29
|
+
assert.equal(lease.acquire('102-other'), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('release is idempotent: releasing an unheld lease, or a slug that does not hold it, is a no-op', () => {
|
|
33
|
+
assert.equal(lease.release('nobody'), false);
|
|
34
|
+
lease.acquire('101-quiet');
|
|
35
|
+
assert.equal(lease.release('wrong-slug'), false);
|
|
36
|
+
assert.equal(lease.isHeld(), true);
|
|
37
|
+
assert.equal(lease.release('101-quiet'), true);
|
|
38
|
+
assert.equal(lease.release('101-quiet'), false);
|
|
39
|
+
});
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// vitest, NOT node:test — this repo's suite is vitest-only (CLAUDE.md).
|
|
17
17
|
import { test } from 'vitest';
|
|
18
18
|
const assert = require('node:assert/strict');
|
|
19
|
-
const { selectReapableJobs } = require('../reaperHelpers.cjs');
|
|
19
|
+
const { selectReapableJobs, mapOutcomeToGateOutcome } = require('../reaperHelpers.cjs');
|
|
20
20
|
|
|
21
21
|
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
22
22
|
const agoMin = (m) => new Date(NOW - m * 60_000).toISOString();
|
|
@@ -110,3 +110,24 @@ test('mixed batch: each row classified independently', () => {
|
|
|
110
110
|
assert.deepStrictEqual(reapable.map((r) => r.slug).sort(), ['dead', 'zombie']);
|
|
111
111
|
assert.deepStrictEqual(warnings.map((w) => w.slug), ['bad-started-at']);
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// mapOutcomeToGateOutcome — classifyRunOutcome's four-way result collapsed
|
|
115
|
+
// onto the persisted `gateOutcome` field (PRD 1109). 'no_result' -> 'never_ran'
|
|
116
|
+
// is the one non-obvious mapping: a missing verdict event means the gate
|
|
117
|
+
// never fired, a distinct fact from 'failed' (a verdict event exists and
|
|
118
|
+
// says error).
|
|
119
|
+
test('mapOutcomeToGateOutcome: success -> passed', () => {
|
|
120
|
+
assert.strictEqual(mapOutcomeToGateOutcome('success'), 'passed');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('mapOutcomeToGateOutcome: failed -> failed', () => {
|
|
124
|
+
assert.strictEqual(mapOutcomeToGateOutcome('failed'), 'failed');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('mapOutcomeToGateOutcome: no_result -> never_ran', () => {
|
|
128
|
+
assert.strictEqual(mapOutcomeToGateOutcome('no_result'), 'never_ran');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('mapOutcomeToGateOutcome: unknown -> unknown', () => {
|
|
132
|
+
assert.strictEqual(mapOutcomeToGateOutcome('unknown'), 'unknown');
|
|
133
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs
|
|
4
|
+
//
|
|
5
|
+
// The launch circuit breaker (lib/launchFailure.cjs, issue #11) hands the
|
|
6
|
+
// picker a Map of slug → hold reason. A held row must be invisible to every
|
|
7
|
+
// pick — it must NOT shadow a pickable sibling behind it, must not make a
|
|
8
|
+
// project a round-robin candidate on its own, and must not be offered as a
|
|
9
|
+
// quiet-machine job — while still surfacing as a per-row hold for the UI.
|
|
10
|
+
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD } = require('../schedulerBatch.cjs');
|
|
13
|
+
const { computeLaunchHolds } = require('../../scheduler.cjs');
|
|
14
|
+
const lf = require('../launchFailure.cjs');
|
|
15
|
+
|
|
16
|
+
const CWD = DEFAULT_PROJECT_CWD;
|
|
17
|
+
|
|
18
|
+
function job(slug, status = 'pending', extra = {}) {
|
|
19
|
+
return {
|
|
20
|
+
slug,
|
|
21
|
+
status,
|
|
22
|
+
cwd: CWD,
|
|
23
|
+
parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
|
|
24
|
+
dependsOn: [],
|
|
25
|
+
createdAt: '2026-09-02T18:00:00.000Z',
|
|
26
|
+
...extra,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('pickForProject: a held row is skipped and the sibling behind it is picked', () => {
|
|
31
|
+
const jobs = [job('10-blocked'), job('11-free')];
|
|
32
|
+
const held = new Map([['10-blocked', 'launch blocked (model_config_rejected) — re-probe in 5 min']]);
|
|
33
|
+
const r = pickForProject(jobs, new Set(), 1, held);
|
|
34
|
+
assert.deepEqual(r.batch.map((j) => j.slug), ['11-free']);
|
|
35
|
+
const hold = r.holds.find((h) => h.slug === '10-blocked');
|
|
36
|
+
assert.ok(hold, 'held row surfaces as a hold record');
|
|
37
|
+
assert.equal(hold.dep, null);
|
|
38
|
+
assert.match(hold.reason, /launch blocked/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('pickForProject: every pending row held → empty batch, holds still reported, no dep reason invented', () => {
|
|
42
|
+
const jobs = [job('10-a'), job('11-b')];
|
|
43
|
+
const held = new Map([['10-a', 'launch blocked'], ['11-b', 'launch blocked']]);
|
|
44
|
+
const r = pickForProject(jobs, new Set(), 3, held);
|
|
45
|
+
assert.equal(r.batch.length, 0);
|
|
46
|
+
assert.equal(r.reason, null);
|
|
47
|
+
assert.deepEqual(r.holds.map((h) => h.slug).sort(), ['10-a', '11-b']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('pickForProject: a held row still counts as a blocking dependency for its dependents', () => {
|
|
51
|
+
const jobs = [job('10-base'), job('11-child', 'pending', { dependsOn: ['10-base'] })];
|
|
52
|
+
const held = new Map([['10-base', 'launch blocked']]);
|
|
53
|
+
const r = pickForProject(jobs, new Set(), 3, held);
|
|
54
|
+
assert.equal(r.batch.length, 0, 'the child must not run ahead of its held dependency');
|
|
55
|
+
const childHold = r.holds.find((h) => h.slug === '11-child');
|
|
56
|
+
assert.equal(childHold.dep, '10-base');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('pickNextBatch: heldSlugs option threads through; a project with only held rows is not a candidate', () => {
|
|
60
|
+
const other = '/tmp/other-project';
|
|
61
|
+
const jobs = [job('10-held'), job('20-other', 'pending', { cwd: other })];
|
|
62
|
+
const held = new Map([['10-held', 'launch blocked (auth_failed)']]);
|
|
63
|
+
const r = pickNextBatch(jobs, new Set(), 2, { heldSlugs: held, now: Date.parse('2026-09-02T18:05:00Z') });
|
|
64
|
+
assert.deepEqual(r.batch.map((j) => j.slug), ['20-other']);
|
|
65
|
+
assert.ok(r.holds.some((h) => h.slug === '10-held' && /auth_failed/.test(h.reason)));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('pickNextBatch: a held quiet-machine row is never dispatched exclusively', () => {
|
|
69
|
+
const jobs = [job('10-quiet', 'pending', { quietMachine: true }), job('11-plain')];
|
|
70
|
+
const held = new Map([['10-quiet', 'launch blocked']]);
|
|
71
|
+
const r = pickNextBatch(jobs, new Set(), 1, { heldSlugs: held, machineInUse: 0 });
|
|
72
|
+
assert.deepEqual(r.batch.map((j) => j.slug), ['11-plain']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('pickNextBatch: nothing pickable at all → empty batch with holds, not a crash', () => {
|
|
76
|
+
const jobs = [job('10-held')];
|
|
77
|
+
const r = pickNextBatch(jobs, new Set(), 2, { heldSlugs: new Map([['10-held', 'launch blocked']]) });
|
|
78
|
+
assert.equal(r.batch.length, 0);
|
|
79
|
+
assert.equal(r.holds[0].slug, '10-held');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── computeLaunchHolds (scheduler.cjs) ─────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
const T0 = Date.parse('2026-09-02T18:00:00Z');
|
|
85
|
+
|
|
86
|
+
test('computeLaunchHolds: no blocks → empty map without probing the CLI', async () => {
|
|
87
|
+
const held = await computeLaunchHolds({ jobs: [job('10-a')], launchBlocks: {} }, { now: T0, claudeVersion: 'v1' });
|
|
88
|
+
assert.equal(held.size, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('computeLaunchHolds: inside backoff every pending row of the blocked persona is held; other personas run', async () => {
|
|
92
|
+
const block = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: 'v1' });
|
|
93
|
+
const state = {
|
|
94
|
+
jobs: [job('10-a', 'pending', { agentType: 'dev-lead' }), job('11-b', 'pending', { agentType: 'dev-lead' }), job('12-c', 'pending', { agentType: 'architect' }), job('13-done', 'completed', { agentType: 'dev-lead' })],
|
|
95
|
+
launchBlocks: { 'dev-lead': block },
|
|
96
|
+
};
|
|
97
|
+
const held = await computeLaunchHolds(state, { now: T0 + 1000, claudeVersion: 'v1' });
|
|
98
|
+
assert.deepEqual([...held.keys()].sort(), ['10-a', '11-b']);
|
|
99
|
+
assert.match(held.get('10-a'), /launch blocked \(model_config_rejected\)/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('computeLaunchHolds: after backoff exactly ONE row per persona is released as the probe', async () => {
|
|
103
|
+
const block = lf.armLaunchBlock(null, { kind: 'api_overloaded', message: 'm', now: T0, claudeVersion: 'v1' });
|
|
104
|
+
const state = {
|
|
105
|
+
jobs: [job('10-a', 'pending', { agentType: 'dev-lead' }), job('11-b', 'pending', { agentType: 'dev-lead' })],
|
|
106
|
+
launchBlocks: { 'dev-lead': block },
|
|
107
|
+
};
|
|
108
|
+
const held = await computeLaunchHolds(state, { now: Date.parse(block.until) + 1, claudeVersion: 'v1' });
|
|
109
|
+
assert.deepEqual([...held.keys()], ['11-b']);
|
|
110
|
+
assert.match(held.get('11-b'), /waiting for this tick's probe/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('computeLaunchHolds: a CLI version change releases everything', async () => {
|
|
114
|
+
const block = lf.armLaunchBlock(null, { kind: 'model_config_rejected', message: 'm', now: T0, claudeVersion: 'v1' });
|
|
115
|
+
const state = { jobs: [job('10-a', 'pending', { agentType: 'dev-lead' })], launchBlocks: { 'dev-lead': block } };
|
|
116
|
+
const held = await computeLaunchHolds(state, { now: T0 + 1, claudeVersion: 'v2' });
|
|
117
|
+
assert.equal(held.size, 0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('computeLaunchHolds: rows with no agentType are keyed as default', async () => {
|
|
121
|
+
const block = lf.armLaunchBlock(null, { kind: 'auth_failed', message: 'm', now: T0, claudeVersion: 'v1' });
|
|
122
|
+
const state = { jobs: [job('10-a')], launchBlocks: { default: block } };
|
|
123
|
+
const held = await computeLaunchHolds(state, { now: T0 + 1, claudeVersion: 'v1' });
|
|
124
|
+
assert.ok(held.has('10-a'));
|
|
125
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs
|
|
4
|
+
//
|
|
5
|
+
// Pure-logic coverage for the `quietMachine: true` exclusive lease (PRD
|
|
6
|
+
// 1107): dispatched only when zero other jobs are running machine-wide,
|
|
7
|
+
// blocks every other dispatch while its lease is held, degrades (dispatches
|
|
8
|
+
// anyway) once it has waited past the configurable interval, and never
|
|
9
|
+
// touches the ordinary dispatch path for a PRD that doesn't opt in.
|
|
10
|
+
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const { pickNextBatch } = require('../schedulerBatch.cjs');
|
|
13
|
+
|
|
14
|
+
const CWD_A = '/home/user/Projects/project-a';
|
|
15
|
+
const CWD_B = '/home/user/Projects/project-b';
|
|
16
|
+
|
|
17
|
+
function job(slug, status, cwd, extra = {}) {
|
|
18
|
+
return {
|
|
19
|
+
slug,
|
|
20
|
+
status,
|
|
21
|
+
cwd,
|
|
22
|
+
parallelGroup: Number(String(slug).match(/^(\d+)-/)?.[1] ?? 99),
|
|
23
|
+
dependsOn: [],
|
|
24
|
+
...extra,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ORIGINAL_WAIT_ENV = process.env.SM_QUIET_MACHINE_WAIT_MINUTES;
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (ORIGINAL_WAIT_ENV === undefined) delete process.env.SM_QUIET_MACHINE_WAIT_MINUTES;
|
|
31
|
+
else process.env.SM_QUIET_MACHINE_WAIT_MINUTES = ORIGINAL_WAIT_ENV;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('a quietMachine job dispatches alone when the machine is idle, even with ordinary work also pending', () => {
|
|
35
|
+
const jobs = [
|
|
36
|
+
job('101-quiet', 'pending', CWD_A, { quietMachine: true, createdAt: new Date().toISOString() }),
|
|
37
|
+
job('102-ordinary', 'pending', CWD_A, { createdAt: new Date().toISOString() }),
|
|
38
|
+
job('201-other', 'pending', CWD_B, { createdAt: new Date().toISOString() }),
|
|
39
|
+
];
|
|
40
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5, { leaseHeld: false, machineInUse: 0, now: Date.now() });
|
|
41
|
+
assert.deepEqual(batch.map((j) => j.slug), ['101-quiet']);
|
|
42
|
+
assert.equal(batch[0].quietLeaseDegraded, false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a quietMachine job does NOT dispatch while another job is running machine-wide, and ordinary jobs dispatch unaffected', () => {
|
|
46
|
+
const jobs = [
|
|
47
|
+
job('101-quiet', 'pending', CWD_A, { quietMachine: true, createdAt: new Date().toISOString() }),
|
|
48
|
+
job('201-other', 'pending', CWD_B, { createdAt: new Date().toISOString() }),
|
|
49
|
+
];
|
|
50
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5, { leaseHeld: false, machineInUse: 1, now: Date.now() });
|
|
51
|
+
assert.deepEqual(batch.map((j) => j.slug), ['201-other']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('leaseHeld blocks EVERY project from dispatching, not just the quiet job\'s own project', () => {
|
|
55
|
+
const jobs = [
|
|
56
|
+
job('101-quiet', 'running', CWD_A, { quietMachine: true }),
|
|
57
|
+
job('201-other', 'pending', CWD_B, { createdAt: new Date().toISOString() }),
|
|
58
|
+
];
|
|
59
|
+
const { batch, reason } = pickNextBatch(jobs, new Set(['101-quiet']), 5, { leaseHeld: true, machineInUse: 1, now: Date.now() });
|
|
60
|
+
assert.deepEqual(batch, []);
|
|
61
|
+
assert.match(reason, /quiet-machine/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a quietMachine job blocked by dependsOn is skipped even when the machine is idle', () => {
|
|
65
|
+
const jobs = [
|
|
66
|
+
job('100-dep', 'pending', CWD_A, { createdAt: new Date().toISOString() }),
|
|
67
|
+
job('101-quiet', 'pending', CWD_A, { quietMachine: true, dependsOn: ['100-dep'], createdAt: new Date().toISOString() }),
|
|
68
|
+
];
|
|
69
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5, { leaseHeld: false, machineInUse: 0, now: Date.now() });
|
|
70
|
+
// The quiet job is blocked by its dep; the dep itself is an ordinary job
|
|
71
|
+
// and dispatches normally.
|
|
72
|
+
assert.deepEqual(batch.map((j) => j.slug), ['100-dep']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a quietMachine job waiting under the configured interval stays held while the machine is busy', () => {
|
|
76
|
+
process.env.SM_QUIET_MACHINE_WAIT_MINUTES = '30';
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const queuedAt = new Date(now - 10 * 60_000).toISOString(); // waited 10m
|
|
79
|
+
const jobs = [
|
|
80
|
+
job('101-quiet', 'pending', CWD_A, { quietMachine: true, createdAt: queuedAt }),
|
|
81
|
+
];
|
|
82
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5, { leaseHeld: false, machineInUse: 1, now });
|
|
83
|
+
assert.deepEqual(batch, []);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a quietMachine job past the configured wait interval dispatches anyway, marked degraded', () => {
|
|
87
|
+
process.env.SM_QUIET_MACHINE_WAIT_MINUTES = '30';
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const queuedAt = new Date(now - 31 * 60_000).toISOString(); // waited 31m
|
|
90
|
+
const jobs = [
|
|
91
|
+
job('101-quiet', 'pending', CWD_A, { quietMachine: true, createdAt: queuedAt }),
|
|
92
|
+
];
|
|
93
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5, { leaseHeld: false, machineInUse: 1, now });
|
|
94
|
+
assert.deepEqual(batch.map((j) => j.slug), ['101-quiet']);
|
|
95
|
+
assert.equal(batch[0].quietLeaseDegraded, true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('ordinary dispatch path is unchanged when no quietMachine job is present (no quietOpts passed at all)', () => {
|
|
99
|
+
const jobs = [
|
|
100
|
+
job('101-a', 'pending', CWD_A),
|
|
101
|
+
job('102-b', 'pending', CWD_A),
|
|
102
|
+
job('201-a', 'pending', CWD_B),
|
|
103
|
+
];
|
|
104
|
+
const { batch } = pickNextBatch(jobs, new Set(), 5);
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
batch.map((j) => j.slug).sort(),
|
|
107
|
+
['101-a', '102-b', '201-a'],
|
|
108
|
+
);
|
|
109
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schedulerMcpServerHeadlessRefusal.test.cjs — scheduler_create_prd is
|
|
3
|
+
* refused at the authoring boundary when called from inside a headless
|
|
4
|
+
* scheduled job (issue #11 list C1: PRD 460 called /develop from inside its
|
|
5
|
+
* own run, spawned a duplicate PRD, and exited 0 having done nothing).
|
|
6
|
+
* scheduler.cjs stamps SM_SCHEDULER_JOB_SLUG on every job spawn and
|
|
7
|
+
* SM_SCHEDULER_JOB_MAY_QUEUE=1 only for decomposition personas.
|
|
8
|
+
*
|
|
9
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
import { test, expect, afterEach } from 'vitest';
|
|
15
|
+
const fsp = require('node:fs/promises');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const SERVER_PATH = path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs');
|
|
20
|
+
|
|
21
|
+
const tmpDirs = [];
|
|
22
|
+
const savedEnv = {};
|
|
23
|
+
for (const k of ['SM_SCHEDULER_JOB_SLUG', 'SM_SCHEDULER_JOB_MAY_QUEUE', 'HOME']) savedEnv[k] = process.env[k];
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
for (const [k, v] of Object.entries(savedEnv)) {
|
|
27
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
28
|
+
}
|
|
29
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
30
|
+
while (tmpDirs.length) await fsp.rm(tmpDirs.pop(), { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function freshServer() {
|
|
34
|
+
const homeDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mcp-headless-'));
|
|
35
|
+
tmpDirs.push(homeDir);
|
|
36
|
+
process.env.HOME = homeDir;
|
|
37
|
+
delete require.cache[require.resolve(SERVER_PATH)];
|
|
38
|
+
return require(SERVER_PATH);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PRD_ARGS = { title: 'x', goal: 'y', acceptanceCriteria: ['z'] };
|
|
42
|
+
|
|
43
|
+
test('scheduler_create_prd inside a headless job is refused before any admin request', async () => {
|
|
44
|
+
process.env.SM_SCHEDULER_JOB_SLUG = '460-some-prd';
|
|
45
|
+
delete process.env.SM_SCHEDULER_JOB_MAY_QUEUE;
|
|
46
|
+
const { handleCallTool } = await freshServer();
|
|
47
|
+
const result = await handleCallTool({ params: { name: 'scheduler_create_prd', arguments: PRD_ARGS } });
|
|
48
|
+
expect(result.isError).toBe(true);
|
|
49
|
+
const text = result.content[0].text;
|
|
50
|
+
expect(text).toMatch(/refused/);
|
|
51
|
+
expect(text).toMatch(/460-some-prd/);
|
|
52
|
+
expect(text).toMatch(/never queues follow-on work/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('scheduler_create_prd inside a headless job with SM_SCHEDULER_JOB_MAY_QUEUE=1 is not refused by the gate', async () => {
|
|
56
|
+
process.env.SM_SCHEDULER_JOB_SLUG = '461-architect-plan';
|
|
57
|
+
process.env.SM_SCHEDULER_JOB_MAY_QUEUE = '1';
|
|
58
|
+
const { handleCallTool } = await freshServer();
|
|
59
|
+
const result = await handleCallTool({ params: { name: 'scheduler_create_prd', arguments: PRD_ARGS } });
|
|
60
|
+
// No admin API is configured in this tmp HOME, so the call fails LATER at
|
|
61
|
+
// the admin request — the point is that it was not the headless refusal.
|
|
62
|
+
const text = result.content[0].text;
|
|
63
|
+
expect(text).not.toMatch(/never queues follow-on work/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('scheduler_create_prd outside a headless job is not refused by the gate', async () => {
|
|
67
|
+
delete process.env.SM_SCHEDULER_JOB_SLUG;
|
|
68
|
+
const { handleCallTool } = await freshServer();
|
|
69
|
+
const result = await handleCallTool({ params: { name: 'scheduler_create_prd', arguments: PRD_ARGS } });
|
|
70
|
+
expect(result.content[0].text).not.toMatch(/never queues follow-on work/);
|
|
71
|
+
});
|