claude-code-session-manager 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentLibrary-DyLWzZDf.js +3 -0
- package/dist/assets/{DataModel-DRH-Ty20.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-CfRhT1Im.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-wEmh_U6c.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko-D_t7Rbi7.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-CpArQ-OJ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-pjaKYs84.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ftqaV3kn.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-ChMWkNd0.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-D9Kr40Ai.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-BtChISho.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DBA5EcYy.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-I0_dWNTX.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-5GTEveb2.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-DNBFGrYE.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-DJB6-bBM.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-BiDDrJUA.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-_Wrevtop.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-OWWXdLRy.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-B122N0HL.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-DhvuQL4C.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +15 -14
- package/plugins/CLAUDE.md +61 -0
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +1 -1
- package/scripts/scheduler-mcp-server.cjs +7 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +100 -9
- package/src/main/__tests__/broadcastCoalescer.test.cjs +18 -0
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/health-delegation-chain.test.cjs +2 -1
- package/src/main/__tests__/health-starve-escalation.test.cjs +94 -0
- package/src/main/__tests__/loadGateDetailTick.test.cjs +31 -0
- package/src/main/__tests__/machineProfile.test.cjs +19 -1
- package/src/main/__tests__/needsReviewLedger.test.cjs +162 -0
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +3 -3
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +15 -1
- package/src/main/__tests__/prdCreateDisposition.test.cjs +201 -0
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +125 -0
- package/src/main/__tests__/prdLocations.test.cjs +100 -2
- package/src/main/__tests__/prdLocationsArchived.test.cjs +43 -1
- package/src/main/__tests__/prdSetDisposition.test.cjs +222 -0
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +96 -0
- package/src/main/__tests__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +147 -0
- package/src/main/__tests__/queueHistory.test.cjs +63 -0
- package/src/main/__tests__/reconcileTiming.test.cjs +135 -0
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +101 -1
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +2 -2
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +121 -0
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +43 -0
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +121 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +344 -0
- package/src/main/__tests__/scheduler-job-budget.test.cjs +172 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +93 -3
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +189 -0
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +152 -0
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +164 -0
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +165 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +160 -0
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +87 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +88 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +154 -0
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/__tests__/telemetryClient.test.cjs +75 -5
- package/src/main/__tests__/telemetrySettings.test.cjs +32 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +76 -3
- package/src/main/historyAggregator.cjs +5 -0
- package/src/main/index.cjs +95 -44
- package/src/main/ipcSchemas.cjs +47 -0
- package/src/main/lib/__tests__/active-sessions.test.cjs +251 -0
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +107 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +322 -43
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +239 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +89 -0
- package/src/main/lib/__tests__/guardShims.test.cjs +151 -0
- package/src/main/lib/__tests__/loadGate.test.cjs +103 -2
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +5 -5
- package/src/main/lib/__tests__/prdDisposition.test.cjs +224 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +179 -1
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +224 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +312 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +193 -0
- package/{scripts → src/main}/lib/activeSessions.cjs +50 -4
- package/src/main/lib/agentModelResolve.cjs +65 -27
- package/src/main/lib/bootSelfHeal.cjs +88 -0
- package/src/main/lib/delegationReadiness.cjs +290 -225
- package/src/main/lib/effectiveModelInfo.cjs +333 -0
- package/src/main/lib/ephemeralCwd.cjs +1 -1
- package/src/main/lib/epicMint.cjs +3 -3
- package/src/main/lib/gitWorktree.cjs +42 -12
- package/src/main/lib/guardShims.cjs +156 -0
- package/src/main/lib/jobDirtFilter.cjs +7 -2
- package/src/main/lib/launchFailure.cjs +2 -1
- package/src/main/lib/loadGate.cjs +23 -1
- package/src/main/lib/machineProfile.cjs +15 -0
- package/src/main/lib/mcpToolCatalog.cjs +4 -1
- package/src/main/lib/needsReviewLedger.cjs +205 -0
- package/src/main/lib/opsErrorLog.cjs +1 -1
- package/src/main/lib/opsOwnership.cjs +1 -1
- package/src/main/lib/prdCreate.cjs +56 -1
- package/src/main/lib/prdDisposition.cjs +199 -0
- package/src/main/lib/prdFrontmatter.cjs +8 -2
- package/src/main/lib/prdLocations.cjs +167 -45
- package/src/main/lib/projectHomeAdminRoutes.cjs +4 -4
- package/src/main/lib/projectPageSummarySchema.cjs +1 -1
- package/src/main/lib/projectRootResolve.cjs +1 -1
- package/src/main/lib/queueHistory.cjs +19 -1
- package/src/main/lib/queueStore.cjs +6 -1
- package/src/main/lib/reaperHelpers.cjs +181 -15
- package/src/main/lib/scheduleJobSchema.cjs +8 -0
- package/src/main/lib/scheduleJobTransitions.cjs +33 -0
- package/src/main/lib/schedulerBatch.cjs +12 -1
- package/src/main/lib/schedulerConfig.cjs +37 -0
- package/src/main/lib/telemetryBoot.cjs +11 -8
- package/src/main/lib/telemetryClient.cjs +44 -2
- package/src/main/lib/telemetrySettings.cjs +20 -3
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/pty.cjs +9 -0
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1869 -193
- package/src/main/templates/PRD_AUTHORING.md +50 -0
- package/src/main/templates/project-pages-catalog.json +1 -1
- package/src/main/usage.cjs +21 -3
- package/src/preload/api.d.ts +92 -1
- package/src/preload/index.cjs +10 -0
- package/web/README.md +41 -0
- package/{scripts/render-project-pages.cjs → web/project-pages/render.cjs} +4 -4
- package/{scripts/render-project-pages → web/project-pages/renderer}/dist/renderer.cjs +1 -1
- package/{scripts/validate-project-pages-summary.cjs → web/project-pages/validate-summary.cjs} +5 -5
- package/dist/assets/AgentLibrary-Bkv-HcP1.js +0 -3
- package/dist/assets/MarkdownEditor-Xc141kjj.js +0 -1
- package/dist/assets/Permissions-DKoNVgzj.js +0 -3
- package/dist/assets/Scheduler-CbES7MC8.js +0 -14
- package/dist/assets/Settings-BX3FElXk.js +0 -3
- package/dist/assets/settingsSchema-sGoCTd7J.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effectiveModelInfo.test.cjs — the main-process half of "what will this
|
|
3
|
+
* Epic actually run as": overlay-aware persona alias resolution plus
|
|
4
|
+
* evidence-based concrete model id (scheduler run log, then session
|
|
5
|
+
* transcript), never fabricated when there's no evidence.
|
|
6
|
+
*
|
|
7
|
+
* All fixtures live under a per-test mkdtemp'd $HOME (never the real
|
|
8
|
+
* ~/.claude) so persona lookup (which reads os.homedir()), scheduler run
|
|
9
|
+
* logs, and transcripts all resolve consistently.
|
|
10
|
+
*
|
|
11
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/effectiveModelInfo.test.cjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const { encodeCwd } = require('../encodeCwd.cjs');
|
|
21
|
+
const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortReachable } = require('../effectiveModelInfo.cjs');
|
|
22
|
+
// config.cjs's `allowedRoots` is a module-level singleton seeded from
|
|
23
|
+
// os.homedir() at FIRST require — since this test's HOME changes every test
|
|
24
|
+
// (fresh mkdtemp), a persona lookup (getPersonaBody -> validatePath) would
|
|
25
|
+
// only pass for whichever tmpHome happened to be active the first time
|
|
26
|
+
// config.cjs got lazily required. addAllowedRoot mutates the live Set
|
|
27
|
+
// instead, so every test's own tmpHome is explicitly admitted.
|
|
28
|
+
const { addAllowedRoot } = require('../../config.cjs');
|
|
29
|
+
|
|
30
|
+
let tmpHome;
|
|
31
|
+
let originalHome;
|
|
32
|
+
let cwd;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
originalHome = process.env.HOME;
|
|
36
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-effective-model-home-'));
|
|
37
|
+
process.env.HOME = tmpHome;
|
|
38
|
+
addAllowedRoot(tmpHome);
|
|
39
|
+
cwd = path.join(tmpHome, 'project');
|
|
40
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
process.env.HOME = originalHome;
|
|
45
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function writePersona(dir, name, frontmatterLines) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
const lines = ['---', ...frontmatterLines, '---', 'Body text.'];
|
|
51
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeGlobalPersona(name, frontmatterLines) {
|
|
55
|
+
writePersona(path.join(tmpHome, '.claude', 'agents'), name, frontmatterLines);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeOverlayPersona(name, frontmatterLines) {
|
|
59
|
+
writePersona(path.join(cwd, '.claude', 'agents'), name, frontmatterLines);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeActiveIndex(sessions) {
|
|
63
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions, events: {} }, null, 2));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeQueueJobs(jobs) {
|
|
69
|
+
const dir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
70
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
+
fs.writeFileSync(path.join(dir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeHistoryEntries(entries) {
|
|
75
|
+
const dir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
76
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeRunLog(runId, slug, text) {
|
|
81
|
+
const dir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', runId);
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
fs.writeFileSync(path.join(dir, `${slug}.log`), text);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function writeTranscript(sessionId, lines) {
|
|
87
|
+
const dir = path.join(tmpHome, '.claude', 'projects', encodeCwd(cwd));
|
|
88
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
89
|
+
fs.writeFileSync(path.join(dir, `${sessionId}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- persona alias resolution ----------
|
|
93
|
+
|
|
94
|
+
test('resolves a global persona alias with modelSource "persona" and no fabricated evidence', async () => {
|
|
95
|
+
writeGlobalPersona('writer', ['model: sonnet']);
|
|
96
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'writer' });
|
|
97
|
+
expect(info.modelAlias).toBe('sonnet');
|
|
98
|
+
expect(info.modelSource).toBe('persona');
|
|
99
|
+
expect(info.resolvedModelId).toBeNull();
|
|
100
|
+
expect(info.resolvedFrom).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a persona model of "inherit" reports modelAlias null and modelSource "inherit"', async () => {
|
|
104
|
+
writeGlobalPersona('inheriting', ['model: inherit']);
|
|
105
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'inheriting' });
|
|
106
|
+
expect(info.modelAlias).toBeNull();
|
|
107
|
+
expect(info.modelSource).toBe('inherit');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a persona with no model field at all is treated the same as inherit', async () => {
|
|
111
|
+
writeGlobalPersona('bare', []);
|
|
112
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'bare' });
|
|
113
|
+
expect(info.modelAlias).toBeNull();
|
|
114
|
+
expect(info.modelSource).toBe('inherit');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a persona pinned to an already-concrete model id echoes it back with resolvedFrom null, ignoring conflicting evidence', async () => {
|
|
118
|
+
writeGlobalPersona('pinned', ['model: claude-opus-5-2027']);
|
|
119
|
+
// Conflicting scheduler evidence exists but must never override an
|
|
120
|
+
// already-concrete alias — no evidence lookup should even be attempted.
|
|
121
|
+
writeQueueJobs([{ slug: 'x', runId: 'r1', agentType: 'pinned', finishedAt: new Date().toISOString() }]);
|
|
122
|
+
writeRunLog('r1', 'x', '[scheduler] agentType=pinned persona=/whatever model=claude-sonnet-1\n');
|
|
123
|
+
|
|
124
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'pinned' });
|
|
125
|
+
expect(info.modelAlias).toBe('claude-opus-5-2027');
|
|
126
|
+
expect(info.modelSource).toBe('persona');
|
|
127
|
+
expect(info.resolvedModelId).toBe('claude-opus-5-2027');
|
|
128
|
+
expect(info.resolvedFrom).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a project-overlay persona wins over the global definition and reports modelSource "persona-overlay"', async () => {
|
|
132
|
+
writeGlobalPersona('shared-name', ['model: sonnet']);
|
|
133
|
+
writeOverlayPersona('shared-name', ['model: opus']);
|
|
134
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'shared-name' });
|
|
135
|
+
expect(info.modelAlias).toBe('opus');
|
|
136
|
+
expect(info.modelSource).toBe('persona-overlay');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('a dangling agentType (no persona file anywhere) resolves modelSource "fallback" without throwing', async () => {
|
|
140
|
+
await expect(resolveEffectiveModelInfo({ cwd, agentType: 'ghost-persona' })).resolves.toBeTruthy();
|
|
141
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'ghost-persona' });
|
|
142
|
+
expect(info.modelAlias).toBeNull();
|
|
143
|
+
expect(info.modelSource).toBe('fallback');
|
|
144
|
+
expect(info.resolvedModelId).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------- concrete-model-id evidence ----------
|
|
148
|
+
|
|
149
|
+
test('evidence hit via the scheduler run record: reads the model logged in the most recent matching job\'s run log', async () => {
|
|
150
|
+
writeGlobalPersona('runner', ['model: sonnet']);
|
|
151
|
+
writeQueueJobs([
|
|
152
|
+
{ slug: 'older', runId: 'run-older', agentType: 'runner', finishedAt: '2026-01-01T00:00:00.000Z' },
|
|
153
|
+
{ slug: 'newer', runId: 'run-newer', agentType: 'runner', finishedAt: '2026-02-01T00:00:00.000Z' },
|
|
154
|
+
]);
|
|
155
|
+
writeRunLog('run-older', 'older', '[scheduler] agentType=runner persona=/p/runner.md model=claude-old-1\n');
|
|
156
|
+
writeRunLog('run-newer', 'newer', '[scheduler] agentType=runner persona=/p/runner.md model=claude-sonnet-5\n');
|
|
157
|
+
|
|
158
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'runner' });
|
|
159
|
+
expect(info.resolvedModelId).toBe('claude-sonnet-5');
|
|
160
|
+
expect(info.resolvedFrom).toBe('scheduler-run');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('scheduler-run evidence also matches jobs already archived into history.jsonl', async () => {
|
|
164
|
+
writeGlobalPersona('archived-runner', ['model: opus']);
|
|
165
|
+
writeHistoryEntries([
|
|
166
|
+
{ slug: 'old-hist', runId: 'run-hist', agentType: 'archived-runner', finishedAt: '2026-01-01T00:00:00.000Z' },
|
|
167
|
+
]);
|
|
168
|
+
writeRunLog('run-hist', 'old-hist', '[scheduler] agentType=archived-runner persona=/p/x.md model=claude-opus-5\n');
|
|
169
|
+
|
|
170
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'archived-runner' });
|
|
171
|
+
expect(info.resolvedModelId).toBe('claude-opus-5');
|
|
172
|
+
expect(info.resolvedFrom).toBe('scheduler-run');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('evidence hit via transcript when no scheduler run exists: takes the LAST "model" occurrence', async () => {
|
|
176
|
+
writeGlobalPersona('chatty', ['model: opus']);
|
|
177
|
+
writeActiveIndex({
|
|
178
|
+
'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', agentType: 'chatty', createdAt: '2026-03-01T00:00:00.000Z' },
|
|
179
|
+
});
|
|
180
|
+
writeTranscript('sess-1', [
|
|
181
|
+
{ type: 'assistant', message: { model: 'claude-opus-4' } },
|
|
182
|
+
{ type: 'assistant', message: { model: 'claude-opus-5' } },
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'chatty' });
|
|
186
|
+
expect(info.resolvedModelId).toBe('claude-opus-5');
|
|
187
|
+
expect(info.resolvedFrom).toBe('transcript');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('transcript evidence prefers the most recently created matching Epic', async () => {
|
|
191
|
+
writeGlobalPersona('multi-epic', ['model: haiku']);
|
|
192
|
+
writeActiveIndex({
|
|
193
|
+
'epic-old': { id: 'epic-old', claudeSessionId: 'sess-old', agentType: 'multi-epic', createdAt: '2026-01-01T00:00:00.000Z' },
|
|
194
|
+
'epic-new': { id: 'epic-new', claudeSessionId: 'sess-new', agentType: 'multi-epic', createdAt: '2026-05-01T00:00:00.000Z' },
|
|
195
|
+
});
|
|
196
|
+
writeTranscript('sess-old', [{ model: 'claude-haiku-old' }]);
|
|
197
|
+
writeTranscript('sess-new', [{ model: 'claude-haiku-new' }]);
|
|
198
|
+
|
|
199
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'multi-epic' });
|
|
200
|
+
expect(info.resolvedModelId).toBe('claude-haiku-new');
|
|
201
|
+
expect(info.resolvedFrom).toBe('transcript');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('evidence miss (no scheduler run, no transcript) reports resolvedModelId/resolvedFrom as null — never a guessed pricing-key fallback', async () => {
|
|
205
|
+
writeGlobalPersona('unseen', ['model: haiku']);
|
|
206
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'unseen' });
|
|
207
|
+
expect(info.resolvedModelId).toBeNull();
|
|
208
|
+
expect(info.resolvedFrom).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---------- edge cases ----------
|
|
212
|
+
|
|
213
|
+
test('never throws for a missing cwd/agentType', async () => {
|
|
214
|
+
await expect(resolveEffectiveModelInfo({})).resolves.toMatchObject({
|
|
215
|
+
modelAlias: null,
|
|
216
|
+
modelSource: 'fallback',
|
|
217
|
+
resolvedModelId: null,
|
|
218
|
+
resolvedFrom: null,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('isConcreteModelId treats known CLI aliases as non-concrete and everything else as concrete', () => {
|
|
223
|
+
expect(isConcreteModelId('opus')).toBe(false);
|
|
224
|
+
expect(isConcreteModelId('sonnet')).toBe(false);
|
|
225
|
+
expect(isConcreteModelId('haiku')).toBe(false);
|
|
226
|
+
expect(isConcreteModelId('fable')).toBe(false);
|
|
227
|
+
expect(isConcreteModelId(null)).toBe(false);
|
|
228
|
+
expect(isConcreteModelId('claude-opus-5')).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('effortReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
|
|
232
|
+
expect(computeEffortReachable({})).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('resolveEffectiveModelInfo surfaces effortReachable alongside the model fields', async () => {
|
|
236
|
+
writeGlobalPersona('any', ['model: sonnet']);
|
|
237
|
+
const info = await resolveEffectiveModelInfo({ cwd, agentType: 'any' });
|
|
238
|
+
expect(info.effortReachable).toBe(false);
|
|
239
|
+
});
|
|
@@ -300,6 +300,95 @@ test('[job] carry-over failure (git apply cannot apply) degrades cleanly: worktr
|
|
|
300
300
|
}
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
+
// ──────────────────────────────────────────── PRD: stream the base-WIP diff capture (job 1192)
|
|
304
|
+
|
|
305
|
+
test('[job] a wide dirty tree (300+ changed paths incl. a multi-MB file) captures and carries over without a maxBuffer overflow', async () => {
|
|
306
|
+
const files = [];
|
|
307
|
+
for (let i = 0; i < 300; i++) {
|
|
308
|
+
const rel = `wide-${i}.txt`;
|
|
309
|
+
fs.writeFileSync(path.join(repoCwd, rel), `initial ${i}\n`, 'utf8');
|
|
310
|
+
files.push(rel);
|
|
311
|
+
}
|
|
312
|
+
const bigRel = 'big-file.bin';
|
|
313
|
+
fs.writeFileSync(path.join(repoCwd, bigRel), Buffer.alloc(1024, 1));
|
|
314
|
+
git(['add', '-A'], repoCwd);
|
|
315
|
+
git(['commit', '-q', '-m', 'wide baseline'], repoCwd);
|
|
316
|
+
|
|
317
|
+
// Dirty every tracked file (uncommitted tracked edits) and grow the binary
|
|
318
|
+
// file well past Node's execFile default maxBuffer (1MB) — the exact shape
|
|
319
|
+
// that overflowed the old buffered capture (job 1192: ~240 dirty paths).
|
|
320
|
+
for (const rel of files) fs.appendFileSync(path.join(repoCwd, rel), 'dirtied\n', 'utf8');
|
|
321
|
+
const bigBuf = Buffer.alloc(3 * 1024 * 1024); // 3MB of NUL bytes — git detects this as binary
|
|
322
|
+
fs.writeFileSync(path.join(repoCwd, bigRel), bigBuf);
|
|
323
|
+
|
|
324
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'wide-tree' });
|
|
325
|
+
expect(result.ok).toBe(true);
|
|
326
|
+
expect(result.carriedPaths.length).toBe(files.length + 1);
|
|
327
|
+
expect(fs.readFileSync(path.join(result.dir, files[0]), 'utf8')).toContain('dirtied');
|
|
328
|
+
expect(Buffer.compare(fs.readFileSync(path.join(result.dir, bigRel)), bigBuf)).toBe(0);
|
|
329
|
+
|
|
330
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
331
|
+
}, 30_000);
|
|
332
|
+
|
|
333
|
+
test('[job] a renamed file and a mutated binary file survive the capture/apply round trip using the same flags as production', async () => {
|
|
334
|
+
const binRel = 'asset.bin';
|
|
335
|
+
const binInitial = Buffer.from([0, 1, 2, 3, 4, 0, 255, 254]);
|
|
336
|
+
fs.writeFileSync(path.join(repoCwd, binRel), binInitial);
|
|
337
|
+
fs.writeFileSync(path.join(repoCwd, 'to-rename.txt'), 'original name content\n', 'utf8');
|
|
338
|
+
git(['add', '-A'], repoCwd);
|
|
339
|
+
git(['commit', '-q', '-m', 'binary + rename baseline'], repoCwd);
|
|
340
|
+
|
|
341
|
+
// Dirty tree: a tracked rename plus a mutated binary file, both uncommitted.
|
|
342
|
+
git(['mv', 'to-rename.txt', 'renamed.txt'], repoCwd);
|
|
343
|
+
const binChanged = Buffer.from([9, 8, 7, 0, 6, 5, 0, 4]);
|
|
344
|
+
fs.writeFileSync(path.join(repoCwd, binRel), binChanged);
|
|
345
|
+
|
|
346
|
+
const result = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'rename-binary' });
|
|
347
|
+
expect(result.ok).toBe(true);
|
|
348
|
+
expect(fs.existsSync(path.join(result.dir, 'to-rename.txt'))).toBe(false);
|
|
349
|
+
expect(fs.readFileSync(path.join(result.dir, 'renamed.txt'), 'utf8')).toBe('original name content\n');
|
|
350
|
+
expect(Buffer.compare(fs.readFileSync(path.join(result.dir, binRel)), binChanged)).toBe(0);
|
|
351
|
+
|
|
352
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: result.dir, branch: result.branch });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('[job] a capture that cannot write its diff file (target dir missing) falls back with a specific reason, never the old generic maxBuffer message', async () => {
|
|
356
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
357
|
+
const bogusDir = path.join(tmpRoot, 'does-not-exist');
|
|
358
|
+
const result = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: bogusDir });
|
|
359
|
+
expect(result.ok).toBe(false);
|
|
360
|
+
expect(result.reason).toMatch(/capturing base diff failed/);
|
|
361
|
+
expect(result.reason).not.toMatch(/maxBuffer/i);
|
|
362
|
+
expect(fs.existsSync(bogusDir)).toBe(false); // nothing leaked into a directory that never existed
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test('[job] the temp diff file is removed after carry-over on both the success and the git-apply-failure paths', async () => {
|
|
366
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'uncommitted tracked edit\n', 'utf8');
|
|
367
|
+
const success = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-ok' });
|
|
368
|
+
expect(success.ok).toBe(true);
|
|
369
|
+
expect(fs.readdirSync(success.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
|
|
370
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: success.dir, branch: success.branch });
|
|
371
|
+
|
|
372
|
+
// Force a git-apply failure on a genuine clean-base worktree by making the
|
|
373
|
+
// target file read-only before the capture/apply call, so the patch writes
|
|
374
|
+
// fine but applying it fails — the finally block must still remove the temp
|
|
375
|
+
// patch file even though apply itself errored.
|
|
376
|
+
const clean = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cleanup-fail' });
|
|
377
|
+
expect(clean.ok).toBe(true);
|
|
378
|
+
fs.writeFileSync(path.join(repoCwd, 'README.md'), 'another uncommitted tracked edit\n', 'utf8');
|
|
379
|
+
const targetFile = path.join(clean.dir, 'README.md');
|
|
380
|
+
fs.chmodSync(targetFile, 0o444);
|
|
381
|
+
try {
|
|
382
|
+
const fail = await gitWorktree.captureAndCarryBaseDiff({ cwd: repoCwd, dir: clean.dir });
|
|
383
|
+
expect(fail.ok).toBe(false);
|
|
384
|
+
expect(fail.reason).toMatch(/git apply failed/);
|
|
385
|
+
} finally {
|
|
386
|
+
fs.chmodSync(targetFile, 0o644);
|
|
387
|
+
}
|
|
388
|
+
expect(fs.readdirSync(clean.dir).filter((f) => f.startsWith('.sm-worktree-carry-'))).toEqual([]);
|
|
389
|
+
await gitWorktree.cleanupJobWorktree({ cwd: repoCwd, dir: clean.dir, branch: clean.branch });
|
|
390
|
+
}, 30_000);
|
|
391
|
+
|
|
303
392
|
test('[job] falls back once the concurrency cap is reached, and recovers after cleanup', async () => {
|
|
304
393
|
process.env.SM_JOB_WORKTREE_MAX = '1';
|
|
305
394
|
const first = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'cap-1' });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guardShims.test.cjs — unit tests for the stable, app-upgrade-proof guard
|
|
3
|
+
* hook indirection (guardShims.cjs). Uses a temp "home" dir per test so the
|
|
4
|
+
* real ~/.claude is never touched.
|
|
5
|
+
*
|
|
6
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/guardShims.test.cjs
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { test, expect, afterEach } from 'vitest';
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const fsp = require('node:fs/promises');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { execFileSync } = require('node:child_process');
|
|
15
|
+
const {
|
|
16
|
+
APP_ROOT,
|
|
17
|
+
GUARD_NAMES,
|
|
18
|
+
hooksDir,
|
|
19
|
+
pointerPath,
|
|
20
|
+
shimPath,
|
|
21
|
+
writeGuardShims,
|
|
22
|
+
guardShimsExist,
|
|
23
|
+
} = require('../guardShims.cjs');
|
|
24
|
+
|
|
25
|
+
const tmpDirs = [];
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
while (tmpDirs.length) {
|
|
28
|
+
const d = tmpDirs.pop();
|
|
29
|
+
await fsp.rm(d, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function mkTmp(prefix) {
|
|
34
|
+
const d = await fsp.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
35
|
+
tmpDirs.push(d);
|
|
36
|
+
return d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run a guard (by absolute path, e.g. a shim) as a child process, same
|
|
40
|
+
* pattern delegationReadiness.test.cjs uses to exercise the real guards —
|
|
41
|
+
* NEVER require() a guard/shim in-process, since its main() calls
|
|
42
|
+
* process.exit(0) as a require-time side effect. */
|
|
43
|
+
function runGuard(scriptPath, { toolName = 'Write', toolInput, cwd }) {
|
|
44
|
+
return JSON.parse(execFileSync('node', [scriptPath], {
|
|
45
|
+
input: JSON.stringify({ tool_name: toolName, tool_input: toolInput, cwd }),
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('writeGuardShims: writes the pointer file and all three shims under homeDir', async () => {
|
|
51
|
+
const homeDir = await mkTmp('sm-guard-shims-home-');
|
|
52
|
+
const r = await writeGuardShims({ homeDir });
|
|
53
|
+
expect(r.ok).toBe(true);
|
|
54
|
+
|
|
55
|
+
expect(fs.existsSync(pointerPath(homeDir))).toBe(true);
|
|
56
|
+
expect(JSON.parse(fs.readFileSync(pointerPath(homeDir), 'utf8'))).toEqual({ appRoot: APP_ROOT });
|
|
57
|
+
for (const name of GUARD_NAMES) {
|
|
58
|
+
expect(fs.existsSync(shimPath(name, homeDir))).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
expect(guardShimsExist(homeDir)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('writeGuardShims: does NOT vendor guard logic — the shim body only references the guard by name, never copies its source', async () => {
|
|
64
|
+
const homeDir = await mkTmp('sm-guard-shims-home-');
|
|
65
|
+
await writeGuardShims({ homeDir });
|
|
66
|
+
|
|
67
|
+
const realSource = fs.readFileSync(path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'), 'utf8');
|
|
68
|
+
const shimSource = fs.readFileSync(shimPath('guard-prd-writes.cjs', homeDir), 'utf8');
|
|
69
|
+
expect(shimSource).not.toContain('SCHEDULER_SEGMENT'); // a guard-prd-writes-specific symbol
|
|
70
|
+
expect(shimSource.length).toBeLessThan(realSource.length / 2);
|
|
71
|
+
expect(shimSource).toContain("require(path.join(ptr.appRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'))");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('writeGuardShims: idempotent — re-running with the same appRoot produces byte-identical files', async () => {
|
|
75
|
+
const homeDir = await mkTmp('sm-guard-shims-home-');
|
|
76
|
+
const r1 = await writeGuardShims({ homeDir });
|
|
77
|
+
expect(r1.ok).toBe(true);
|
|
78
|
+
const pointerBefore = fs.readFileSync(pointerPath(homeDir), 'utf8');
|
|
79
|
+
const shimsBefore = GUARD_NAMES.map((n) => fs.readFileSync(shimPath(n, homeDir), 'utf8'));
|
|
80
|
+
|
|
81
|
+
const r2 = await writeGuardShims({ homeDir });
|
|
82
|
+
expect(r2.ok).toBe(true);
|
|
83
|
+
expect(fs.readFileSync(pointerPath(homeDir), 'utf8')).toBe(pointerBefore);
|
|
84
|
+
GUARD_NAMES.forEach((n, i) => {
|
|
85
|
+
expect(fs.readFileSync(shimPath(n, homeDir), 'utf8')).toBe(shimsBefore[i]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('writeGuardShims: the installed shim actually resolves through the pointer and runs the real guard logic', async () => {
|
|
90
|
+
const homeDir = await mkTmp('sm-guard-shims-home-');
|
|
91
|
+
const cwd = await mkTmp('sm-guard-shims-cwd-');
|
|
92
|
+
await writeGuardShims({ homeDir });
|
|
93
|
+
|
|
94
|
+
const denied = runGuard(shimPath('guard-prd-writes.cjs', homeDir), {
|
|
95
|
+
toolInput: { file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' },
|
|
96
|
+
cwd,
|
|
97
|
+
});
|
|
98
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
99
|
+
|
|
100
|
+
const allowed = runGuard(shimPath('guard-prd-writes.cjs', homeDir), {
|
|
101
|
+
toolInput: { file_path: path.join(cwd, 'src', 'x.ts'), content: 'x' },
|
|
102
|
+
cwd,
|
|
103
|
+
});
|
|
104
|
+
expect(allowed.hookSpecificOutput?.permissionDecision).not.toBe('deny');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('writeGuardShims: rewriting the pointer to a DIFFERENT app root lets an already-installed shim follow an app upgrade with no re-install', async () => {
|
|
108
|
+
const homeDir = await mkTmp('sm-guard-shims-home-');
|
|
109
|
+
const cwd = await mkTmp('sm-guard-shims-cwd-');
|
|
110
|
+
await writeGuardShims({ homeDir }); // "first install", points at the real APP_ROOT
|
|
111
|
+
const shimFile = shimPath('guard-prd-writes.cjs', homeDir);
|
|
112
|
+
const shimBytesBefore = fs.readFileSync(shimFile, 'utf8');
|
|
113
|
+
|
|
114
|
+
// Simulate an app upgrade: a second "app root" at a brand-new path, with
|
|
115
|
+
// its own copy of scripts/hooks/guard-prd-writes.cjs (this is the ONLY file
|
|
116
|
+
// guardShims ever reads through the pointer at invocation time).
|
|
117
|
+
const secondAppRoot = await mkTmp('sm-guard-shims-upgraded-app-');
|
|
118
|
+
await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks'), { recursive: true });
|
|
119
|
+
await fsp.copyFile(
|
|
120
|
+
path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
|
|
121
|
+
path.join(secondAppRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const r = await writeGuardShims({ homeDir, appRoot: secondAppRoot });
|
|
125
|
+
expect(r.ok).toBe(true);
|
|
126
|
+
expect(JSON.parse(fs.readFileSync(pointerPath(homeDir), 'utf8')).appRoot).toBe(secondAppRoot);
|
|
127
|
+
// The shim FILE at the already-installed path never changed.
|
|
128
|
+
expect(fs.readFileSync(shimFile, 'utf8')).toBe(shimBytesBefore);
|
|
129
|
+
|
|
130
|
+
// A project hook that already references this exact shim path keeps working.
|
|
131
|
+
const denied = runGuard(shimFile, {
|
|
132
|
+
toolInput: { file_path: path.join(cwd, 'session-manager-operations', 'scheduler', 'prds', '1234-x.md'), content: '# x' },
|
|
133
|
+
cwd,
|
|
134
|
+
});
|
|
135
|
+
expect(denied.hookSpecificOutput?.permissionDecision).toBe('deny');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('writeGuardShims: returns { ok:false, error } without throwing when homeDir cannot be written to', async () => {
|
|
139
|
+
const base = await mkTmp('sm-guard-shims-unwritable-');
|
|
140
|
+
const blocker = path.join(base, 'blocker-file');
|
|
141
|
+
await fsp.writeFile(blocker, 'x', 'utf8');
|
|
142
|
+
// A homeDir whose parent path segment is a plain FILE can never be mkdir'd
|
|
143
|
+
// into (ENOTDIR) — reproduces "unwritable ~/.claude" without relying on
|
|
144
|
+
// permission bits, which root would bypass.
|
|
145
|
+
const homeDir = path.join(blocker, 'home');
|
|
146
|
+
|
|
147
|
+
const r = await writeGuardShims({ homeDir });
|
|
148
|
+
expect(r.ok).toBe(false);
|
|
149
|
+
expect(r.error).toBeTruthy();
|
|
150
|
+
expect(guardShimsExist(homeDir)).toBe(false);
|
|
151
|
+
});
|
|
@@ -125,7 +125,7 @@ test('an ungated tick never audits', () => {
|
|
|
125
125
|
assert.equal(g.evaluate().shouldAudit, false);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
-
test('escalates once the gated stretch exceeds the escalation window, and the stretch resets
|
|
128
|
+
test('escalates once the gated stretch exceeds the escalation window, and the stretch only resets once load has stayed sub-threshold past the release window', () => {
|
|
129
129
|
let l1 = 12.95;
|
|
130
130
|
let t = 0;
|
|
131
131
|
const g = createLoadGate({
|
|
@@ -134,6 +134,7 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
|
|
|
134
134
|
now: () => t,
|
|
135
135
|
threshold: 0.85,
|
|
136
136
|
escalateAfterMs: 45 * 60_000,
|
|
137
|
+
releaseWindowMs: 2 * 60_000,
|
|
137
138
|
});
|
|
138
139
|
assert.equal(g.evaluate().escalate, false);
|
|
139
140
|
t += 44 * 60_000;
|
|
@@ -142,12 +143,43 @@ test('escalates once the gated stretch exceeds the escalation window, and the st
|
|
|
142
143
|
const r = g.evaluate();
|
|
143
144
|
assert.equal(r.escalate, true, 'at 45m: escalates');
|
|
144
145
|
assert.equal(r.gatedSinceMs, 45 * 60_000);
|
|
146
|
+
// A single sub-threshold sample must NOT reset the stretch (the boundary-
|
|
147
|
+
// hovering bug this PRD fixes) — gatedSinceMs keeps growing.
|
|
145
148
|
l1 = 1; t += 60_000;
|
|
146
|
-
|
|
149
|
+
const oneMinBelow = g.evaluate();
|
|
150
|
+
assert.equal(oneMinBelow.gated, false, 'gate decision is immediate');
|
|
151
|
+
assert.equal(oneMinBelow.gatedSinceMs, 46 * 60_000, 'stretch not yet released');
|
|
152
|
+
// Load climbs back above threshold before the release window elapses: the
|
|
153
|
+
// stretch was never actually cleared, so it just keeps accumulating.
|
|
154
|
+
l1 = 12.95; t += 30_000;
|
|
155
|
+
assert.equal(g.evaluate().gatedSinceMs, 46 * 60_000 + 30_000, 'stretch survived the brief dip');
|
|
156
|
+
// Now hold sub-threshold continuously past the release window (the window
|
|
157
|
+
// is measured from the first sub-threshold sample of this sustained drop,
|
|
158
|
+
// so it takes a tick to mark that start, then another once the window has
|
|
159
|
+
// actually elapsed).
|
|
160
|
+
l1 = 1; t += 1;
|
|
161
|
+
g.evaluate();
|
|
162
|
+
t += 2 * 60_000;
|
|
163
|
+
assert.equal(g.evaluate().gatedSinceMs, 0, 'sustained sub-threshold load past the release window clears the stretch');
|
|
147
164
|
l1 = 12.95; t += 60_000;
|
|
148
165
|
assert.equal(g.evaluate().gatedSinceMs, 0, 'a fresh stretch starts from zero');
|
|
149
166
|
});
|
|
150
167
|
|
|
168
|
+
test('the gate decision itself is unaffected by hysteresis: a sub-threshold tick is never gated, even mid-stretch', () => {
|
|
169
|
+
let l1 = 12.95;
|
|
170
|
+
let t = 0;
|
|
171
|
+
const g = createLoadGate({
|
|
172
|
+
loadavg: () => [l1, 0, 0],
|
|
173
|
+
cores: () => 14,
|
|
174
|
+
now: () => t,
|
|
175
|
+
threshold: 0.85,
|
|
176
|
+
releaseWindowMs: 5 * 60_000,
|
|
177
|
+
});
|
|
178
|
+
assert.equal(g.evaluate().gated, true);
|
|
179
|
+
l1 = 1; t += 60_000;
|
|
180
|
+
assert.equal(g.evaluate().gated, false, 'below threshold: gated:false immediately, hysteresis notwithstanding');
|
|
181
|
+
});
|
|
182
|
+
|
|
151
183
|
test('snapshot() reflects the last evaluation and is null before any', () => {
|
|
152
184
|
const { g } = gateWith({});
|
|
153
185
|
assert.equal(g.snapshot(), null);
|
|
@@ -157,3 +189,72 @@ test('snapshot() reflects the last evaluation and is null before any', () => {
|
|
|
157
189
|
assert.equal(typeof s.at, 'string');
|
|
158
190
|
assert.equal('shouldAudit' in s, false, 'per-tick flags are not part of the persisted snapshot');
|
|
159
191
|
});
|
|
192
|
+
|
|
193
|
+
test('snapshot() exposes gated, ratio, threshold, loadavg1, cores and gatedSinceMs (what buildScheduleStatePayload surfaces as loadGate)', () => {
|
|
194
|
+
const { g } = gateWith({});
|
|
195
|
+
g.evaluate();
|
|
196
|
+
const s = g.snapshot();
|
|
197
|
+
assert.equal(typeof s.gated, 'boolean');
|
|
198
|
+
assert.equal(typeof s.ratio, 'number');
|
|
199
|
+
assert.equal(typeof s.threshold, 'number');
|
|
200
|
+
assert.equal(typeof s.loadavg1, 'number');
|
|
201
|
+
assert.equal(typeof s.cores, 'number');
|
|
202
|
+
assert.equal(typeof s.gatedSinceMs, 'number');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── hysteresis: the real observed boundary-hovering sequence ──────────────
|
|
206
|
+
|
|
207
|
+
test('a boundary-hovering box (real observed sequence, alternating above/below threshold) grows gatedSinceMs monotonically across 50 simulated minutes and eventually escalates', () => {
|
|
208
|
+
// 14 cores, 0.85/core = 11.9 threshold; loadavg1 observed 2026-09-12
|
|
209
|
+
// oscillating 11.07 - 13.31, straddling the threshold every tick. Before
|
|
210
|
+
// this PRD, the immediate reset on ANY sub-threshold sample meant every
|
|
211
|
+
// one of these ticks reset gatedSince to null and escalate never fired.
|
|
212
|
+
const CORES = 14;
|
|
213
|
+
const THRESHOLD = 0.85;
|
|
214
|
+
const SEQUENCE = [11.07, 13.31, 11.51, 12.8];
|
|
215
|
+
let l1 = SEQUENCE[0];
|
|
216
|
+
let t = 0;
|
|
217
|
+
const g = createLoadGate({
|
|
218
|
+
loadavg: () => [l1, 0, 0],
|
|
219
|
+
cores: () => CORES,
|
|
220
|
+
now: () => t,
|
|
221
|
+
threshold: THRESHOLD,
|
|
222
|
+
escalateAfterMs: 20 * 60_000, // shorter than the 45m default so 50 sim-minutes crosses it
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const TICK_MS = 60_000;
|
|
226
|
+
const TOTAL_MS = 50 * 60_000;
|
|
227
|
+
let prevGatedSinceMs = -1;
|
|
228
|
+
let sawEscalate = false;
|
|
229
|
+
let i = 0;
|
|
230
|
+
for (let elapsed = 0; elapsed < TOTAL_MS; elapsed += TICK_MS) {
|
|
231
|
+
l1 = SEQUENCE[i % SEQUENCE.length];
|
|
232
|
+
i += 1;
|
|
233
|
+
const r = g.evaluate();
|
|
234
|
+
assert.ok(r.gatedSinceMs >= prevGatedSinceMs, `gatedSinceMs must not shrink (was ${prevGatedSinceMs}, now ${r.gatedSinceMs})`);
|
|
235
|
+
prevGatedSinceMs = r.gatedSinceMs;
|
|
236
|
+
if (r.escalate) sawEscalate = true;
|
|
237
|
+
t += TICK_MS;
|
|
238
|
+
}
|
|
239
|
+
assert.equal(sawEscalate, true, 'escalate must fire once the (never-reset) stretch exceeds escalateAfterMs');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('the release window actually releases: sustained sub-threshold load past the window reports gated:false and gatedSinceMs:0 on the next tick', () => {
|
|
243
|
+
let l1 = 13.31;
|
|
244
|
+
let t = 0;
|
|
245
|
+
const g = createLoadGate({
|
|
246
|
+
loadavg: () => [l1, 0, 0],
|
|
247
|
+
cores: () => 14,
|
|
248
|
+
now: () => t,
|
|
249
|
+
threshold: 0.85,
|
|
250
|
+
releaseWindowMs: 2 * 60_000,
|
|
251
|
+
});
|
|
252
|
+
assert.equal(g.evaluate().gated, true);
|
|
253
|
+
l1 = 5;
|
|
254
|
+
t += 1;
|
|
255
|
+
g.evaluate(); // marks the start of the sustained sub-threshold run
|
|
256
|
+
t += 2 * 60_000;
|
|
257
|
+
const r = g.evaluate();
|
|
258
|
+
assert.equal(r.gated, false);
|
|
259
|
+
assert.equal(r.gatedSinceMs, 0);
|
|
260
|
+
});
|
|
@@ -29,7 +29,7 @@ const fsp = require('node:fs/promises');
|
|
|
29
29
|
const os = require('node:os');
|
|
30
30
|
const path = require('node:path');
|
|
31
31
|
const queueStore = require('../queueStore.cjs');
|
|
32
|
-
const { activeProjectCwds } = require('
|
|
32
|
+
const { activeProjectCwds } = require('../activeSessions.cjs');
|
|
33
33
|
|
|
34
34
|
const tmpDirs = [];
|
|
35
35
|
|
|
@@ -149,7 +149,7 @@ test('a transcript cwd inside an ops root is normalized to the project root, not
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
test('normalization keeps the active-project signal rather than dropping the row', () => {
|
|
152
|
-
const { projectRootOf } = require('
|
|
152
|
+
const { projectRootOf } = require('../activeSessions.cjs');
|
|
153
153
|
expect(projectRootOf('/p/session-manager-operations/prompt-sessions')).toBe('/p');
|
|
154
154
|
expect(projectRootOf('/p/session-manager-operations')).toBe('/p');
|
|
155
155
|
expect(projectRootOf('/p/session-manager-operations/scheduler/state')).toBe('/p');
|
|
@@ -185,7 +185,7 @@ test('projectStateDir fails closed on an ops-internal cwd, naming the offending
|
|
|
185
185
|
// list at all. `worktreeMainRootOf` reverses a linked worktree's `.git` FILE
|
|
186
186
|
// (`gitdir: <main>/.git/worktrees/<name>`) back to `<main>`.
|
|
187
187
|
|
|
188
|
-
const { worktreeMainRootOf } = require('
|
|
188
|
+
const { worktreeMainRootOf } = require('../activeSessions.cjs');
|
|
189
189
|
const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('../gitWorktree.cjs');
|
|
190
190
|
|
|
191
191
|
// A worktree's MAIN tree root must live outside os.tmpdir() for these fixtures
|
|
@@ -285,13 +285,13 @@ test('worktreeMainRootOf returns null for a plain non-git directory', async () =
|
|
|
285
285
|
|
|
286
286
|
test('projectRootOf resolves a worktree cwd to the main tree root', async () => {
|
|
287
287
|
const { main, worktree } = await makeWorktreeFixture();
|
|
288
|
-
const { projectRootOf } = require('
|
|
288
|
+
const { projectRootOf } = require('../activeSessions.cjs');
|
|
289
289
|
expect(projectRootOf(worktree)).toBe(main);
|
|
290
290
|
});
|
|
291
291
|
|
|
292
292
|
test('projectRootOf resolves a cwd nested inside a worktree\'s OWN ops tree to the main root', async () => {
|
|
293
293
|
const { main, worktree } = await makeWorktreeFixture();
|
|
294
|
-
const { projectRootOf } = require('
|
|
294
|
+
const { projectRootOf } = require('../activeSessions.cjs');
|
|
295
295
|
const nestedOps = path.join(worktree, 'session-manager-operations', 'scheduler', 'state');
|
|
296
296
|
fs.mkdirSync(nestedOps, { recursive: true });
|
|
297
297
|
expect(projectRootOf(nestedOps)).toBe(main);
|