claude-code-session-manager 0.86.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-Q4jhl24R.js → DataModel--mISIJ6h.js} +1 -1
- package/dist/assets/{History-Cj2FejEo.js → History-C2ahUXTg.js} +2 -2
- package/dist/assets/{Hooks-CaelQI6t.js → Hooks-BiC6oyR2.js} +3 -3
- package/dist/assets/{HostBilko--v7cMR8I.js → HostBilko-BPleEOld.js} +1 -1
- package/dist/assets/{Library-DgI9oCCZ.js → Library-Dc8Qst1R.js} +1 -1
- package/dist/assets/{ListDetail-DYUZN-x-.js → ListDetail-DIXh-OLX.js} +1 -1
- package/dist/assets/MarkdownEditor-C90bkLXK.js +1 -0
- package/dist/assets/{McpServers-ypCYURh3.js → McpServers-DqcbLOLZ.js} +2 -2
- package/dist/assets/{Memory-C2qYp-3M.js → Memory-CW62MXlh.js} +4 -4
- package/dist/assets/{Panel-Cj2kw-Zv.js → Panel-Bw1FhRuF.js} +1 -1
- package/dist/assets/Permissions-BcUC-5y8.js +3 -0
- package/dist/assets/{Plugins-C1Vj8_dU.js → Plugins-BnKx9flD.js} +2 -2
- package/dist/assets/{ProvenanceBadge-DczPNM5U.js → ProvenanceBadge-Bw5vNVPT.js} +1 -1
- package/dist/assets/{SaveBar-Cd_7U6Gb.js → SaveBar-CWr0O_w-.js} +1 -1
- package/dist/assets/Scheduler-DYdLuUqq.js +14 -0
- package/dist/assets/{ScopeSwitcher-DVSyI44-.js → ScopeSwitcher-CrBLbg8s.js} +1 -1
- package/dist/assets/Settings-DluB-vN1.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CUv1_Q2c.js → SkillReferenceGraph-CHLSseay.js} +1 -1
- package/dist/assets/{Skills-C_YHkAy-.js → Skills-gNdo_HNK.js} +2 -2
- package/dist/assets/{SystemPrompt-B8R7T9xn.js → SystemPrompt-Cru05-Ia.js} +1 -1
- package/dist/assets/{TagLibrary-dj9YHWyy.js → TagLibrary-DNHY0xou.js} +1 -1
- package/dist/assets/{TiptapBody-DnSBUjHE.js → TiptapBody-I4lmbCgP.js} +1 -1
- package/dist/assets/{Toggle-CjV_BJn6.js → Toggle-bWMHjmRh.js} +1 -1
- package/dist/assets/{index-CDo9xBR9.css → index-DV3PorRY.css} +1 -1
- package/dist/assets/{index-CXFQIPhO.js → index-fc_JjdxL.js} +724 -724
- package/dist/assets/settingsSchema-BfhtZnGD.js +3 -0
- package/dist/index.html +2 -2
- package/package.json +14 -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__/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__/queue-health-verdict.test.cjs +170 -0
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +14 -2
- 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-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-porcelain-rename.test.cjs +164 -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 +14 -4
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +14 -0
- package/src/main/chatRunner.cjs +8 -5
- package/src/main/health.cjs +1 -1
- 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__/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__/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/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/schedulerConfig.cjs +24 -0
- package/src/main/lib/usageCircuit.cjs +159 -0
- package/{scripts → src/main}/lib/watchdogHelpers.cjs +1 -1
- package/src/main/scheduler/prdParser.cjs +13 -0
- package/src/main/scheduler.cjs +1285 -143
- 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-DTFL7y8G.js +0 -3
- package/dist/assets/MarkdownEditor-DCIubYWf.js +0 -1
- package/dist/assets/Permissions-BiYZNGYW.js +0 -3
- package/dist/assets/Scheduler-DcLBiJBq.js +0 -14
- package/dist/assets/Settings-Cv-pRyms.js +0 -3
- package/dist/assets/settingsSchema-BJVciriw.js +0 -3
- /package/{scripts/project-pages-logic → web/project-pages/logic}/dist/logic.cjs +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prdDisposition.test.cjs — pure logic behind the scheduler wave-disposition
|
|
3
|
+
* PRD: does a new PRD wave extend an Epic's existing dependsOn chain
|
|
4
|
+
* ('append', gaining a dependsOn on the chain's current terminal PRD(s)) or
|
|
5
|
+
* start an independent, parallel-eligible root ('new-head')? And, after the
|
|
6
|
+
* fact, can a human safely rewrite that choice from the Scheduler UI?
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/prdDisposition.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect } from 'vitest';
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
isIncomplete,
|
|
17
|
+
resolveChainTerminals,
|
|
18
|
+
wouldCreateCycle,
|
|
19
|
+
computeDispositionRewrite,
|
|
20
|
+
} = require('../prdDisposition.cjs');
|
|
21
|
+
|
|
22
|
+
function row(overrides) {
|
|
23
|
+
return { slug: 'x', status: 'pending', dependsOn: [], ...overrides };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────── isIncomplete
|
|
27
|
+
|
|
28
|
+
test('isIncomplete: completed is the only complete status', () => {
|
|
29
|
+
expect(isIncomplete('completed')).toBe(false);
|
|
30
|
+
for (const status of [null, undefined, 'pending', 'running', 'needs_review', 'failed', 'quarantined']) {
|
|
31
|
+
expect(isIncomplete(status)).toBe(true);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────── resolveChainTerminals
|
|
36
|
+
|
|
37
|
+
test('resolveChainTerminals: a lone root is its own terminal', () => {
|
|
38
|
+
const rows = [row({ slug: 'a' })];
|
|
39
|
+
expect(resolveChainTerminals(rows)).toEqual(['a']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('resolveChainTerminals: a linear chain terminates at its tail', () => {
|
|
43
|
+
const rows = [
|
|
44
|
+
row({ slug: 'a' }),
|
|
45
|
+
row({ slug: 'b', dependsOn: ['a'] }),
|
|
46
|
+
row({ slug: 'c', dependsOn: ['b'] }),
|
|
47
|
+
];
|
|
48
|
+
expect(resolveChainTerminals(rows)).toEqual(['c']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('resolveChainTerminals: a fork yields both leaves', () => {
|
|
52
|
+
const rows = [
|
|
53
|
+
row({ slug: 'a' }),
|
|
54
|
+
row({ slug: 'b', dependsOn: ['a'] }),
|
|
55
|
+
row({ slug: 'c', dependsOn: ['a'] }),
|
|
56
|
+
];
|
|
57
|
+
expect(resolveChainTerminals(rows).sort()).toEqual(['b', 'c']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resolveChainTerminals: two independent roots are both terminals (existing multi-head Epic)', () => {
|
|
61
|
+
const rows = [row({ slug: 'a' }), row({ slug: 'z' })];
|
|
62
|
+
expect(resolveChainTerminals(rows).sort()).toEqual(['a', 'z']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('resolveChainTerminals: a dependsOn cycle contributes no terminal', () => {
|
|
66
|
+
const rows = [
|
|
67
|
+
row({ slug: 'a', dependsOn: ['b'] }),
|
|
68
|
+
row({ slug: 'b', dependsOn: ['a'] }),
|
|
69
|
+
];
|
|
70
|
+
expect(resolveChainTerminals(rows)).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('resolveChainTerminals: empty input yields no terminals', () => {
|
|
74
|
+
expect(resolveChainTerminals([])).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────── wouldCreateCycle
|
|
78
|
+
|
|
79
|
+
test('wouldCreateCycle: false for a fresh, unrelated edge', () => {
|
|
80
|
+
const rows = [row({ slug: 'a' }), row({ slug: 'b' })];
|
|
81
|
+
expect(wouldCreateCycle(rows, 'b', ['a'])).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('wouldCreateCycle: true when the target already (transitively) depends on the source', () => {
|
|
85
|
+
// a depends on b; proposing b depends on a closes the loop.
|
|
86
|
+
const rows = [
|
|
87
|
+
row({ slug: 'a', dependsOn: ['b'] }),
|
|
88
|
+
row({ slug: 'b' }),
|
|
89
|
+
];
|
|
90
|
+
expect(wouldCreateCycle(rows, 'b', ['a'])).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('wouldCreateCycle: true for a direct self-reference', () => {
|
|
94
|
+
const rows = [row({ slug: 'a' })];
|
|
95
|
+
expect(wouldCreateCycle(rows, 'a', ['a'])).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('wouldCreateCycle: false through an unrelated third row', () => {
|
|
99
|
+
const rows = [
|
|
100
|
+
row({ slug: 'a' }),
|
|
101
|
+
row({ slug: 'b', dependsOn: ['a'] }),
|
|
102
|
+
row({ slug: 'c' }),
|
|
103
|
+
];
|
|
104
|
+
expect(wouldCreateCycle(rows, 'c', ['b'])).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────── computeDispositionRewrite
|
|
108
|
+
|
|
109
|
+
test('append sets the expected edges', () => {
|
|
110
|
+
const rows = [
|
|
111
|
+
row({ slug: 'wave1-tail' }),
|
|
112
|
+
row({ slug: 'wave2-root' }),
|
|
113
|
+
];
|
|
114
|
+
const result = computeDispositionRewrite({
|
|
115
|
+
slug: 'wave2-root',
|
|
116
|
+
disposition: 'append',
|
|
117
|
+
dependsOn: ['wave1-tail'],
|
|
118
|
+
rows,
|
|
119
|
+
});
|
|
120
|
+
expect(result).toEqual({ ok: true, dependsOn: ['wave1-tail'] });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('new-head sets none', () => {
|
|
124
|
+
const rows = [
|
|
125
|
+
row({ slug: 'wave1-tail' }),
|
|
126
|
+
row({ slug: 'wave2-root', dependsOn: ['wave1-tail'] }),
|
|
127
|
+
];
|
|
128
|
+
const result = computeDispositionRewrite({
|
|
129
|
+
slug: 'wave2-root',
|
|
130
|
+
disposition: 'new-head',
|
|
131
|
+
dependsOn: [],
|
|
132
|
+
rows,
|
|
133
|
+
});
|
|
134
|
+
expect(result).toEqual({ ok: true, dependsOn: [] });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('promoting a wave to a head removes exactly its root edges', () => {
|
|
138
|
+
const rows = [
|
|
139
|
+
row({ slug: 'wave1-tail' }),
|
|
140
|
+
row({ slug: 'other-head' }),
|
|
141
|
+
row({ slug: 'wave2-root', dependsOn: ['wave1-tail'] }),
|
|
142
|
+
];
|
|
143
|
+
const result = computeDispositionRewrite({
|
|
144
|
+
slug: 'wave2-root',
|
|
145
|
+
disposition: 'new-head',
|
|
146
|
+
dependsOn: [],
|
|
147
|
+
rows,
|
|
148
|
+
});
|
|
149
|
+
expect(result.ok).toBe(true);
|
|
150
|
+
expect(result.dependsOn).toEqual([]);
|
|
151
|
+
// The unrelated head's own row is untouched — this function only ever
|
|
152
|
+
// returns the target row's new dependsOn, never mutates `rows`.
|
|
153
|
+
expect(rows.find((r) => r.slug === 'other-head').dependsOn).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('a change that would create a cycle is rejected', () => {
|
|
157
|
+
const rows = [
|
|
158
|
+
row({ slug: 'a', dependsOn: ['b'] }),
|
|
159
|
+
row({ slug: 'b' }),
|
|
160
|
+
];
|
|
161
|
+
const result = computeDispositionRewrite({
|
|
162
|
+
slug: 'b',
|
|
163
|
+
disposition: 'append',
|
|
164
|
+
dependsOn: ['a'],
|
|
165
|
+
rows,
|
|
166
|
+
});
|
|
167
|
+
expect(result.ok).toBe(false);
|
|
168
|
+
expect(result.error).toMatch(/cycle/i);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('running rows are untouched', () => {
|
|
172
|
+
const rows = [
|
|
173
|
+
row({ slug: 'wave1-tail' }),
|
|
174
|
+
row({ slug: 'wave2-root', status: 'running', dependsOn: ['wave1-tail'] }),
|
|
175
|
+
];
|
|
176
|
+
const result = computeDispositionRewrite({
|
|
177
|
+
slug: 'wave2-root',
|
|
178
|
+
disposition: 'new-head',
|
|
179
|
+
dependsOn: [],
|
|
180
|
+
rows,
|
|
181
|
+
});
|
|
182
|
+
expect(result.ok).toBe(false);
|
|
183
|
+
expect(result.error).toMatch(/running/i);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('completed rows are untouched', () => {
|
|
187
|
+
const rows = [
|
|
188
|
+
row({ slug: 'wave1-tail' }),
|
|
189
|
+
row({ slug: 'wave2-root', status: 'completed', dependsOn: ['wave1-tail'] }),
|
|
190
|
+
];
|
|
191
|
+
const result = computeDispositionRewrite({
|
|
192
|
+
slug: 'wave2-root',
|
|
193
|
+
disposition: 'append',
|
|
194
|
+
dependsOn: ['wave1-tail'],
|
|
195
|
+
rows,
|
|
196
|
+
});
|
|
197
|
+
expect(result.ok).toBe(false);
|
|
198
|
+
expect(result.error).toMatch(/completed/i);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('refuses to rewrite a row whose existing blocker has already completed', () => {
|
|
202
|
+
const rows = [
|
|
203
|
+
row({ slug: 'blocker', status: 'completed' }),
|
|
204
|
+
row({ slug: 'dependent', status: 'pending', dependsOn: ['blocker'] }),
|
|
205
|
+
];
|
|
206
|
+
const result = computeDispositionRewrite({
|
|
207
|
+
slug: 'dependent',
|
|
208
|
+
disposition: 'new-head',
|
|
209
|
+
dependsOn: [],
|
|
210
|
+
rows,
|
|
211
|
+
});
|
|
212
|
+
expect(result.ok).toBe(false);
|
|
213
|
+
expect(result.error).toMatch(/already completed|satisfied/i);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('unknown slug is refused, not silently ignored', () => {
|
|
217
|
+
const result = computeDispositionRewrite({
|
|
218
|
+
slug: 'ghost',
|
|
219
|
+
disposition: 'new-head',
|
|
220
|
+
dependsOn: [],
|
|
221
|
+
rows: [row({ slug: 'a' })],
|
|
222
|
+
});
|
|
223
|
+
expect(result.ok).toBe(false);
|
|
224
|
+
});
|
|
@@ -23,7 +23,8 @@ const { spawn } = require('node:child_process');
|
|
|
23
23
|
const {
|
|
24
24
|
selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome,
|
|
25
25
|
findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome,
|
|
26
|
-
isAlreadySatisfiedOnMain,
|
|
26
|
+
isAlreadySatisfiedOnMain, resolvePidlessFailureOverride,
|
|
27
|
+
readSpawnedPidFromLog, isLogFresh,
|
|
27
28
|
} = require('../reaperHelpers.cjs');
|
|
28
29
|
const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
|
|
29
30
|
|
|
@@ -339,6 +340,28 @@ test('findLiveProcessForJob: no live process anywhere → null', () => {
|
|
|
339
340
|
assert.strictEqual(pid, null);
|
|
340
341
|
});
|
|
341
342
|
|
|
343
|
+
// findLiveProcessForJob: runCwd — the 2026-09-13 in-place/shared-tree fix.
|
|
344
|
+
// A run with no dedicated worktree (no worktreeDir) and no slug in argv
|
|
345
|
+
// (dispatch never puts it there) is invisible to both existing matches; the
|
|
346
|
+
// runCwd+cmdline-claude combo match exists for exactly this shape. `sleep`
|
|
347
|
+
// is used as the /proc-visible process here (not a real `claude` binary), so
|
|
348
|
+
// this exercises the cwd-plus-cmdline-substring code path directly rather
|
|
349
|
+
// than depending on a real claude binary being installed.
|
|
350
|
+
test('findLiveProcessForJob: runCwd match requires BOTH cwd AND a claude-looking cmdline — bare cwd match is never sufficient', async () => {
|
|
351
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
352
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-runcwd-'));
|
|
353
|
+
// Runs in `dir` but its cmdline has no "claude" in it — must NOT match.
|
|
354
|
+
const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
|
|
355
|
+
try {
|
|
356
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
357
|
+
const pid = findLiveProcessForJob({ slug: 'unrelated-slug' }, { runCwd: dir });
|
|
358
|
+
assert.strictEqual(pid, null, 'a bare cwd match without a claude-looking cmdline must never match — shared-tree cross-match hazard');
|
|
359
|
+
} finally {
|
|
360
|
+
child.kill('SIGKILL');
|
|
361
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
342
365
|
// logHasOutput — the literal "did the run dir produce any log output" check
|
|
343
366
|
// that gates whether a pidless reap may assert gateOutcome: 'never_ran'.
|
|
344
367
|
|
|
@@ -397,3 +420,158 @@ test('isAlreadySatisfiedOnMain: no satisfying commits → null — the gate must
|
|
|
397
420
|
assert.strictEqual(isAlreadySatisfiedOnMain(null), null);
|
|
398
421
|
assert.strictEqual(isAlreadySatisfiedOnMain(undefined), null);
|
|
399
422
|
});
|
|
423
|
+
|
|
424
|
+
// resolvePidlessFailureOverride — PRD 1173. A pidless reap must never stamp
|
|
425
|
+
// 'failed' on a row that already carries proof (landedCommit) that its work
|
|
426
|
+
// shipped, purely because reproting bookkeeping (runtime.pid) was lost.
|
|
427
|
+
|
|
428
|
+
test('resolvePidlessFailureOverride: row with a landedCommit → override naming the recorded commit sha', () => {
|
|
429
|
+
const override = resolvePidlessFailureOverride({ slug: 'shipped-but-pidless', landedCommit: 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f' });
|
|
430
|
+
assert.ok(override, 'a row with landedCommit must produce a failure override');
|
|
431
|
+
assert.strictEqual(override.verdict, 'pidless_reap_with_landed_commit');
|
|
432
|
+
assert.strictEqual(override.landedCommit, 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f');
|
|
433
|
+
assert.match(override.reason, /f3e35a7f9e49f13a9eb8c338b0d086160e32a87f/, 'the reason string must name the recorded commit sha so a human can go straight to `git show <sha>`');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test('resolvePidlessFailureOverride: row with no landedCommit → null, existing failed transition untouched', () => {
|
|
437
|
+
assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran' }), null);
|
|
438
|
+
assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran', landedCommit: null }), null);
|
|
439
|
+
assert.strictEqual(resolvePidlessFailureOverride({ slug: 'genuinely-never-ran', landedCommit: '' }), null);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// readSpawnedPidFromLog — recovers the pid a pidless row's spawn actually
|
|
443
|
+
// produced from the run's own log (2026-09-13 incident: runId
|
|
444
|
+
// 2026-09-13T16-15-56-818Z spawned pid=3138458, logged it, but the
|
|
445
|
+
// runtime.pid persistence mutate failed silently and the pidless-grace
|
|
446
|
+
// reaper terminalized a live job it could have found here).
|
|
447
|
+
|
|
448
|
+
test('readSpawnedPidFromLog: parses the scheduler\'s own spawned-pid line', () => {
|
|
449
|
+
const p = writeTmpLog('some preamble\n[scheduler] spawned pid=3138458 sessionId=345ca5b1-c7a7-4167-9b55-f4905e3afe01 (process group)\n\nmore output\n');
|
|
450
|
+
assert.strictEqual(readSpawnedPidFromLog(p), 3138458);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('readSpawnedPidFromLog: missing file → null', () => {
|
|
454
|
+
assert.strictEqual(readSpawnedPidFromLog('/nonexistent/path/does-not-exist.log'), null);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('readSpawnedPidFromLog: empty file → null', () => {
|
|
458
|
+
const p = writeTmpLog('');
|
|
459
|
+
assert.strictEqual(readSpawnedPidFromLog(p), null);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('readSpawnedPidFromLog: log with no spawned-pid line → null', () => {
|
|
463
|
+
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
|
|
464
|
+
assert.strictEqual(readSpawnedPidFromLog(p), null);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('readSpawnedPidFromLog: null path → null', () => {
|
|
468
|
+
assert.strictEqual(readSpawnedPidFromLog(null), null);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// isLogFresh — pure predicate: a log modified within the window is positive
|
|
472
|
+
// proof of life, independent of runtime.pid bookkeeping.
|
|
473
|
+
|
|
474
|
+
test('isLogFresh: mtime within window → true', () => {
|
|
475
|
+
assert.strictEqual(isLogFresh(NOW - 60_000, NOW, 20 * 60_000), true);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test('isLogFresh: mtime outside window → false', () => {
|
|
479
|
+
assert.strictEqual(isLogFresh(NOW - 30 * 60_000, NOW, 20 * 60_000), false);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('isLogFresh: missing/invalid mtime or window → false', () => {
|
|
483
|
+
assert.strictEqual(isLogFresh(null, NOW, 20 * 60_000), false);
|
|
484
|
+
assert.strictEqual(isLogFresh(NaN, NOW, 20 * 60_000), false);
|
|
485
|
+
assert.strictEqual(isLogFresh(NOW - 60_000, NOW, null), false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// selectReapableJobs — Part B wiring: a pidless row past grace now consults
|
|
489
|
+
// the run log for a pid, and a fresh log skips the row entirely, BEFORE
|
|
490
|
+
// falling back to findLiveProcess or the existing terminal reap.
|
|
491
|
+
|
|
492
|
+
test('selectReapableJobs: pidless + past grace + log yields a LIVE pid → recovered, never reapable, row stays running', () => {
|
|
493
|
+
const jobs = [{ slug: 'zombie-log-alive', status: 'running', startedAt: agoMin(464) }];
|
|
494
|
+
const pidAlive = (pid) => pid === 3138458;
|
|
495
|
+
const getLogPid = () => 3138458;
|
|
496
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
|
|
497
|
+
pidAlive, grace: GRACE, getLogPid,
|
|
498
|
+
});
|
|
499
|
+
assert.deepStrictEqual(reapable, []);
|
|
500
|
+
assert.deepStrictEqual(warnings, []);
|
|
501
|
+
assert.strictEqual(recovered.length, 1);
|
|
502
|
+
assert.strictEqual(recovered[0].slug, 'zombie-log-alive');
|
|
503
|
+
assert.strictEqual(recovered[0].pid, 3138458);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test('selectReapableJobs: pidless + past grace + log yields a DEAD pid → still reaps (no regression)', () => {
|
|
507
|
+
const jobs = [{ slug: 'zombie-log-dead', status: 'running', startedAt: agoMin(464) }];
|
|
508
|
+
const getLogPid = () => 3138458;
|
|
509
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, {
|
|
510
|
+
pidAlive: alwaysDead, grace: GRACE, getLogPid,
|
|
511
|
+
});
|
|
512
|
+
assert.deepStrictEqual(recovered, []);
|
|
513
|
+
assert.strictEqual(reapable.length, 1);
|
|
514
|
+
assert.strictEqual(reapable[0].pidless, true);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('selectReapableJobs: pidless + past grace + FRESH log mtime → skipped entirely (neither reapable nor recovered)', () => {
|
|
518
|
+
const jobs = [{ slug: 'chatty-pidless', status: 'running', startedAt: agoMin(464) }];
|
|
519
|
+
const getLogMtimeMs = () => NOW - 60_000; // modified 1 minute ago
|
|
520
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
|
|
521
|
+
pidAlive: alwaysAlive, grace: GRACE, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
|
|
522
|
+
});
|
|
523
|
+
assert.deepStrictEqual(reapable, []);
|
|
524
|
+
assert.deepStrictEqual(recovered, []);
|
|
525
|
+
assert.strictEqual(warnings.length, 1);
|
|
526
|
+
assert.strictEqual(warnings[0].slug, 'chatty-pidless');
|
|
527
|
+
assert.match(warnings[0].reason, /log evidence/);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('selectReapableJobs: pidless + past grace + STALE log mtime + no recoverable pid → reaps with the existing reason string, byte-identical', () => {
|
|
531
|
+
const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
|
|
532
|
+
const getLogMtimeMs = () => NOW - 25 * 60_000; // stale relative to a 20m window
|
|
533
|
+
const { reapable } = selectReapableJobs(jobs, NOW, {
|
|
534
|
+
pidAlive: alwaysAlive, grace: GRACE, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
|
|
535
|
+
});
|
|
536
|
+
assert.strictEqual(reapable.length, 1);
|
|
537
|
+
assert.strictEqual(reapable[0].reason, 'reaped: no runtime.pid recorded after 10m — spawn never completed');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('selectReapableJobs: a row WITH a live runtime.pid is untouched by every new code path', () => {
|
|
541
|
+
const jobs = [{ slug: 'live', status: 'running', runtime: { pid: 1 }, startedAt: agoMin(500) }];
|
|
542
|
+
const getLogPid = () => { throw new Error('must never be called for a live-pid row'); };
|
|
543
|
+
const getLogMtimeMs = () => { throw new Error('must never be called for a live-pid row'); };
|
|
544
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, {
|
|
545
|
+
pidAlive: alwaysAlive, grace: GRACE, getLogPid, getLogMtimeMs, logFreshWindowMs: 20 * 60_000,
|
|
546
|
+
});
|
|
547
|
+
assert.deepStrictEqual(reapable, []);
|
|
548
|
+
assert.deepStrictEqual(recovered, []);
|
|
549
|
+
assert.deepStrictEqual(warnings, []);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('selectReapableJobs: pidless + past grace + landedCommit on the row → reapable entry carries a failureOverride', () => {
|
|
553
|
+
const jobs = [{
|
|
554
|
+
slug: 'shipped-but-pidless',
|
|
555
|
+
status: 'running',
|
|
556
|
+
startedAt: agoMin(464),
|
|
557
|
+
landedCommit: 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f',
|
|
558
|
+
}];
|
|
559
|
+
const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
|
|
560
|
+
assert.strictEqual(reapable.length, 1);
|
|
561
|
+
assert.strictEqual(reapable[0].pidless, true);
|
|
562
|
+
assert.ok(reapable[0].failureOverride, 'the reapable entry must carry the evidence-based override');
|
|
563
|
+
assert.strictEqual(reapable[0].failureOverride.landedCommit, 'f3e35a7f9e49f13a9eb8c338b0d086160e32a87f');
|
|
564
|
+
// The existing byte-for-byte reason string is untouched by this PRD — the
|
|
565
|
+
// override is carried as a separate field, not folded into `reason`.
|
|
566
|
+
assert.strictEqual(
|
|
567
|
+
reapable[0].reason,
|
|
568
|
+
'reaped: no runtime.pid recorded after 10m — spawn never completed',
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('selectReapableJobs: pidless + past grace + NO landedCommit on the row → failureOverride is null, unchanged from today', () => {
|
|
573
|
+
const jobs = [{ slug: 'genuinely-never-ran', status: 'running', startedAt: agoMin(464) }];
|
|
574
|
+
const { reapable } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
|
|
575
|
+
assert.strictEqual(reapable.length, 1);
|
|
576
|
+
assert.strictEqual(reapable[0].failureOverride, null);
|
|
577
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usageCircuit.test.cjs — breaker transitions, jitter bounds, bindingWindow
|
|
3
|
+
* fallback, reset freshness, degraded-budget floor/cap, singleFlight dedup.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 300 npx vitest run src/main/lib/__tests__/usageCircuit.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test } from 'vitest';
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const {
|
|
13
|
+
createUsageCircuit,
|
|
14
|
+
backoffWithJitter,
|
|
15
|
+
isResetFresh,
|
|
16
|
+
bindingWindow,
|
|
17
|
+
degradedBudget,
|
|
18
|
+
singleFlight,
|
|
19
|
+
} = require('../usageCircuit.cjs');
|
|
20
|
+
|
|
21
|
+
test('state machine: closed -> open after 3 consecutive failures', () => {
|
|
22
|
+
let nowMs = 0;
|
|
23
|
+
const circuit = createUsageCircuit({ now: () => nowMs });
|
|
24
|
+
assert.strictEqual(circuit.state(), 'closed');
|
|
25
|
+
circuit.recordFailure('meter_rate_limited');
|
|
26
|
+
assert.strictEqual(circuit.state(), 'closed');
|
|
27
|
+
circuit.recordFailure('meter_rate_limited');
|
|
28
|
+
assert.strictEqual(circuit.state(), 'closed');
|
|
29
|
+
circuit.recordFailure('meter_rate_limited');
|
|
30
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('open -> half_open after the backoff interval elapses, exactly one probe granted', () => {
|
|
34
|
+
let nowMs = 0;
|
|
35
|
+
const circuit = createUsageCircuit({ now: () => nowMs });
|
|
36
|
+
circuit.recordFailure('a');
|
|
37
|
+
circuit.recordFailure('a');
|
|
38
|
+
circuit.recordFailure('a');
|
|
39
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
40
|
+
|
|
41
|
+
nowMs += 10_000; // still inside the (>=15s) minimum backoff window
|
|
42
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
43
|
+
|
|
44
|
+
nowMs += 60 * 60 * 1000; // well past any possible backoff interval
|
|
45
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
46
|
+
// Re-querying before the probe's result is recorded must not grant a second probe.
|
|
47
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('half_open -> closed on a successful probe, resetting backoff', () => {
|
|
51
|
+
let nowMs = 0;
|
|
52
|
+
const circuit = createUsageCircuit({ now: () => nowMs });
|
|
53
|
+
circuit.recordFailure('a');
|
|
54
|
+
circuit.recordFailure('a');
|
|
55
|
+
circuit.recordFailure('a');
|
|
56
|
+
nowMs += 60 * 60 * 1000;
|
|
57
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
58
|
+
circuit.recordSuccess({ five_hour: { utilization: 10, resets_at: null } });
|
|
59
|
+
assert.strictEqual(circuit.state(), 'closed');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('half_open -> open on a failed probe, with backoff doubled (probe takes longer to reopen)', () => {
|
|
63
|
+
let nowMs = 0;
|
|
64
|
+
const circuit = createUsageCircuit({ now: () => nowMs });
|
|
65
|
+
circuit.recordFailure('a');
|
|
66
|
+
circuit.recordFailure('a');
|
|
67
|
+
circuit.recordFailure('a');
|
|
68
|
+
nowMs += 60 * 60 * 1000;
|
|
69
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
70
|
+
circuit.recordFailure('a');
|
|
71
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
72
|
+
nowMs += 25_000; // below the doubled 2nd-stage backoff's own 30s floor
|
|
73
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
74
|
+
nowMs += 60 * 60 * 1000;
|
|
75
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('backoffWithJitter: 1000 samples stay within [0.5x, 1.0x] of the doubled base, floor 30s / cap 8min', () => {
|
|
79
|
+
for (let i = 0; i < 1000; i++) {
|
|
80
|
+
const first = backoffWithJitter(0);
|
|
81
|
+
assert.ok(first >= 15_000 && first <= 30_000, `first=${first}`);
|
|
82
|
+
|
|
83
|
+
const second = backoffWithJitter(30_000);
|
|
84
|
+
assert.ok(second >= 30_000 && second <= 60_000, `second=${second}`);
|
|
85
|
+
|
|
86
|
+
const atCap = backoffWithJitter(8 * 60 * 1000);
|
|
87
|
+
assert.ok(atCap >= 4 * 60 * 1000 && atCap <= 8 * 60 * 1000, `atCap=${atCap}`);
|
|
88
|
+
|
|
89
|
+
const beyondCap = backoffWithJitter(20 * 60 * 1000); // doubling would exceed the 8min cap
|
|
90
|
+
assert.ok(beyondCap >= 4 * 60 * 1000 && beyondCap <= 8 * 60 * 1000, `beyondCap=${beyondCap}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('isResetFresh: past -> false, future -> true, null -> false', () => {
|
|
95
|
+
const now = Date.parse('2026-09-13T12:00:00Z');
|
|
96
|
+
assert.strictEqual(isResetFresh('2026-09-13T11:59:59Z', now), false); // past
|
|
97
|
+
assert.strictEqual(isResetFresh('2026-09-13T12:00:00Z', now), false); // at now
|
|
98
|
+
assert.strictEqual(isResetFresh('2026-09-13T12:00:01Z', now), true); // future
|
|
99
|
+
assert.strictEqual(isResetFresh(null, now), false);
|
|
100
|
+
assert.strictEqual(isResetFresh(undefined, now), false);
|
|
101
|
+
assert.strictEqual(isResetFresh('not-a-date', now), false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('bindingWindow: limits[] with weekly_all is_active wins over five_hour', () => {
|
|
105
|
+
const payload = {
|
|
106
|
+
five_hour: { utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
|
|
107
|
+
limits: [
|
|
108
|
+
{ type: 'five_hour', is_active: false, utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
|
|
109
|
+
{ type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
const win = bindingWindow(payload);
|
|
113
|
+
assert.strictEqual(win.name, 'weekly_all');
|
|
114
|
+
assert.strictEqual(win.utilization, 81);
|
|
115
|
+
assert.strictEqual(win.resets_at, '2026-09-19T00:00:00Z');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('bindingWindow: limits[] absent falls back to five_hour', () => {
|
|
119
|
+
const payload = { five_hour: { utilization: 42, resets_at: '2026-09-13T15:00:00Z' } };
|
|
120
|
+
const win = bindingWindow(payload);
|
|
121
|
+
assert.strictEqual(win.name, 'five_hour');
|
|
122
|
+
assert.strictEqual(win.utilization, 42);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('bindingWindow: limits[] present but nothing is_active also falls back to five_hour', () => {
|
|
126
|
+
const payload = {
|
|
127
|
+
five_hour: { utilization: 12, resets_at: null },
|
|
128
|
+
limits: [
|
|
129
|
+
{ type: 'five_hour', is_active: false, utilization: 12, resets_at: null },
|
|
130
|
+
{ type: 'weekly_all', is_active: false, utilization: 81, resets_at: null },
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
const win = bindingWindow(payload);
|
|
134
|
+
assert.strictEqual(win.name, 'five_hour');
|
|
135
|
+
assert.strictEqual(win.utilization, 12);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('degradedBudget: never yields 0 utilization, even when the last good binding window read 0', () => {
|
|
139
|
+
const payload = { five_hour: { utilization: 0, resets_at: null } };
|
|
140
|
+
const { utilization } = degradedBudget(payload, {});
|
|
141
|
+
assert.notStrictEqual(utilization, 0);
|
|
142
|
+
assert.strictEqual(utilization, 100); // no real signal -> conservative default
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
|
|
146
|
+
const payload = {
|
|
147
|
+
five_hour: { utilization: 0, resets_at: null },
|
|
148
|
+
limits: [
|
|
149
|
+
{ type: 'five_hour', is_active: false, utilization: 0, resets_at: null },
|
|
150
|
+
{ type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
const { utilization } = degradedBudget(payload, {});
|
|
154
|
+
assert.strictEqual(utilization, 81);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('degradedBudget: caps concurrency at 2 regardless of a higher configured cap', () => {
|
|
158
|
+
const payload = { five_hour: { utilization: 30, resets_at: null } };
|
|
159
|
+
const { concurrencyCap } = degradedBudget(payload, { configuredCap: 5 });
|
|
160
|
+
assert.strictEqual(concurrencyCap, 2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('degradedBudget: respects a configured cap below 2', () => {
|
|
164
|
+
const payload = { five_hour: { utilization: 30, resets_at: null } };
|
|
165
|
+
const { concurrencyCap } = degradedBudget(payload, { configuredCap: 1 });
|
|
166
|
+
assert.strictEqual(concurrencyCap, 1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('degradedBudget: an executor-observed 429 whose window has not passed pins utilization to 100', () => {
|
|
170
|
+
const payload = { five_hour: { utilization: 5, resets_at: null } };
|
|
171
|
+
const now = Date.parse('2026-09-13T12:00:00Z');
|
|
172
|
+
const { utilization } = degradedBudget(payload, {
|
|
173
|
+
observed429: true,
|
|
174
|
+
resetsAt: '2026-09-13T13:00:00Z', // still in the future
|
|
175
|
+
now,
|
|
176
|
+
});
|
|
177
|
+
assert.strictEqual(utilization, 100);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('degradedBudget: once the 429 window passes, it stops pinning to 100 and falls back to real utilization', () => {
|
|
181
|
+
const payload = { five_hour: { utilization: 5, resets_at: null } };
|
|
182
|
+
const now = Date.parse('2026-09-13T12:00:00Z');
|
|
183
|
+
const { utilization } = degradedBudget(payload, {
|
|
184
|
+
observed429: true,
|
|
185
|
+
resetsAt: '2026-09-13T11:00:00Z', // already passed
|
|
186
|
+
now,
|
|
187
|
+
});
|
|
188
|
+
assert.strictEqual(utilization, 5);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('singleFlight: concurrent callers share the same in-flight promise, fn invoked once', async () => {
|
|
192
|
+
let calls = 0;
|
|
193
|
+
let resolveFn;
|
|
194
|
+
const fn = () => new Promise((resolve) => { resolveFn = resolve; calls += 1; });
|
|
195
|
+
const wrapped = singleFlight(fn);
|
|
196
|
+
|
|
197
|
+
const p1 = wrapped();
|
|
198
|
+
const p2 = wrapped();
|
|
199
|
+
assert.strictEqual(p1, p2);
|
|
200
|
+
assert.strictEqual(calls, 1);
|
|
201
|
+
resolveFn('done');
|
|
202
|
+
assert.strictEqual(await p1, 'done');
|
|
203
|
+
|
|
204
|
+
const p3 = wrapped();
|
|
205
|
+
assert.notStrictEqual(p3, p1);
|
|
206
|
+
assert.strictEqual(calls, 2);
|
|
207
|
+
resolveFn('done2');
|
|
208
|
+
await p3;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('singleFlight: de-dupes even when fn rejects, next call gets a fresh attempt', async () => {
|
|
212
|
+
let calls = 0;
|
|
213
|
+
const fn = () => (calls++ === 0 ? Promise.reject(new Error('boom')) : Promise.resolve('ok'));
|
|
214
|
+
const wrapped = singleFlight(fn);
|
|
215
|
+
|
|
216
|
+
const p1 = wrapped();
|
|
217
|
+
const p2 = wrapped();
|
|
218
|
+
assert.strictEqual(p1, p2);
|
|
219
|
+
await assert.rejects(p1, /boom/);
|
|
220
|
+
|
|
221
|
+
const p3 = wrapped();
|
|
222
|
+
assert.strictEqual(await p3, 'ok');
|
|
223
|
+
assert.strictEqual(calls, 2);
|
|
224
|
+
});
|